feat: build reproducible dataset editor
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { estimateSceneMix, MAX_MIX_WORKING_BYTES } from "/packages/acoustics-core/mixdown.js";
|
||||
|
||||
export function createAudioRuntime() {
|
||||
let worker = null;
|
||||
let rejectWorker = null;
|
||||
let playbackContext = null;
|
||||
let playbackSource = null;
|
||||
|
||||
function cancelMix() {
|
||||
if (!worker) return false;
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
const reject = rejectWorker;
|
||||
rejectWorker = null;
|
||||
reject?.(new DOMException("사용자가 mix 생성을 취소했습니다.", "AbortError"));
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderMix(scene, assetBuffers) {
|
||||
if (worker) throw new Error("이미 확인용 mix를 만들고 있습니다.");
|
||||
const estimate = estimateSceneMix(scene, assetBuffers);
|
||||
const crossThreadBytes = estimate.workingBytes + estimate.assetBytes;
|
||||
if (crossThreadBytes > MAX_MIX_WORKING_BYTES) {
|
||||
throw new Error(
|
||||
`decode buffer와 Worker 복사본을 포함한 예상 메모리 ${(crossThreadBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`${(MAX_MIX_WORKING_BYTES / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다.`,
|
||||
);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const currentWorker = new Worker("/apps/dataset-builder/mix-worker.js", { type: "module" });
|
||||
worker = currentWorker;
|
||||
rejectWorker = reject;
|
||||
currentWorker.addEventListener("message", ({ data }) => {
|
||||
if (worker !== currentWorker) return;
|
||||
worker = null;
|
||||
rejectWorker = null;
|
||||
currentWorker.terminate();
|
||||
if (data.ok) resolve(data.result);
|
||||
else reject(new Error(data.message));
|
||||
});
|
||||
currentWorker.addEventListener("error", (event) => {
|
||||
if (worker !== currentWorker) return;
|
||||
worker = null;
|
||||
rejectWorker = null;
|
||||
currentWorker.terminate();
|
||||
reject(new Error(event.message || "mix Worker가 중단되었습니다."));
|
||||
});
|
||||
currentWorker.postMessage({ scene, assetBuffers: [...assetBuffers] });
|
||||
});
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
if (!playbackSource) return false;
|
||||
playbackSource.onended = null;
|
||||
try {
|
||||
playbackSource.stop();
|
||||
} catch {
|
||||
// 이미 끝난 source는 정리만 한다.
|
||||
}
|
||||
playbackSource.disconnect();
|
||||
playbackSource = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function play(result, onEnded) {
|
||||
stopPlayback();
|
||||
playbackContext ??= new AudioContext();
|
||||
await playbackContext.resume();
|
||||
const buffer = playbackContext.createBuffer(1, result.samples.length, result.sampleRate);
|
||||
buffer.copyToChannel(result.samples, 0);
|
||||
playbackSource = playbackContext.createBufferSource();
|
||||
playbackSource.buffer = buffer;
|
||||
playbackSource.connect(playbackContext.destination);
|
||||
playbackSource.onended = () => {
|
||||
playbackSource?.disconnect();
|
||||
playbackSource = null;
|
||||
onEnded?.();
|
||||
};
|
||||
playbackSource.start();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
cancelMix();
|
||||
stopPlayback();
|
||||
playbackContext?.close();
|
||||
playbackContext = null;
|
||||
}
|
||||
|
||||
return {
|
||||
cancelMix,
|
||||
dispose,
|
||||
get isMixing() {
|
||||
return Boolean(worker);
|
||||
},
|
||||
get isPlaying() {
|
||||
return Boolean(playbackSource);
|
||||
},
|
||||
play,
|
||||
renderMix,
|
||||
stopPlayback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { SOURCE_COLORS } from "./model.js";
|
||||
|
||||
function finitePlane(plane) {
|
||||
return [plane.xMin, plane.xMax, plane.yMin, plane.yMax].every(Number.isFinite) &&
|
||||
plane.xMin < plane.xMax && plane.yMin < plane.yMax;
|
||||
}
|
||||
|
||||
export function coordinateToPixel(position, plane, width, height) {
|
||||
return {
|
||||
x: ((position[0] - plane.xMin) / (plane.xMax - plane.xMin)) * width,
|
||||
y: ((plane.yMax - position[1]) / (plane.yMax - plane.yMin)) * height,
|
||||
};
|
||||
}
|
||||
|
||||
export function pixelToCoordinate(point, plane, width, height) {
|
||||
return {
|
||||
x: plane.xMin + (point.x / width) * (plane.xMax - plane.xMin),
|
||||
y: plane.yMax - (point.y / height) * (plane.yMax - plane.yMin),
|
||||
};
|
||||
}
|
||||
|
||||
function drawGrid(context, width, height, plane) {
|
||||
context.fillStyle = "#0a1512";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.lineWidth = 1;
|
||||
for (let index = 0; index <= 10; index += 1) {
|
||||
const x = (index / 10) * width;
|
||||
const y = (index / 10) * height;
|
||||
context.strokeStyle = index === 5 ? "rgba(101,242,191,.17)" : "rgba(145,170,162,.08)";
|
||||
context.beginPath();
|
||||
context.moveTo(x, 0);
|
||||
context.lineTo(x, height);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(0, y);
|
||||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
context.fillStyle = "rgba(205,225,218,.52)";
|
||||
context.font = "600 11px ui-monospace, SFMono-Regular, Consolas, monospace";
|
||||
context.fillText(`x ${plane.xMin.toFixed(2)} m`, 14, height - 16);
|
||||
context.textAlign = "right";
|
||||
context.fillText(`${plane.xMax.toFixed(2)} m`, width - 14, height - 16);
|
||||
context.textAlign = "left";
|
||||
}
|
||||
|
||||
function drawSource(context, source, index, selected, plane, width, height) {
|
||||
if (!source.position?.every(Number.isFinite)) return;
|
||||
const point = coordinateToPixel(source.position, plane, width, height);
|
||||
if (point.x < -30 || point.x > width + 30 || point.y < -30 || point.y > height + 30) return;
|
||||
const color = SOURCE_COLORS[index % SOURCE_COLORS.length];
|
||||
context.save();
|
||||
context.shadowColor = color;
|
||||
context.shadowBlur = selected ? 22 : 12;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, selected ? 10 : 8, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.shadowBlur = 0;
|
||||
if (selected) {
|
||||
context.strokeStyle = "rgba(255,255,255,.9)";
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, 16, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
const label = `${index + 1} · ${source.name}`;
|
||||
context.font = "700 12px Inter, system-ui, sans-serif";
|
||||
const labelWidth = context.measureText(label).width + 18;
|
||||
const labelX = Math.max(8, Math.min(width - labelWidth - 8, point.x + 18));
|
||||
const labelY = Math.max(26, Math.min(height - 10, point.y - 12));
|
||||
context.fillStyle = "rgba(6,15,12,.88)";
|
||||
context.fillRect(labelX, labelY - 18, labelWidth, 27);
|
||||
context.fillStyle = "#eef8f4";
|
||||
context.fillText(label, labelX + 9, labelY);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function createSceneCanvas(canvas, emptyElement, badgeElement, onMove) {
|
||||
const context = canvas.getContext("2d");
|
||||
let current = null;
|
||||
|
||||
function render(scene, image, plane, selectedSourceId) {
|
||||
current = { scene, image, plane, selectedSourceId };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = Math.max(1, rect.width);
|
||||
const height = Math.max(1, rect.height);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const targetWidth = Math.round(width * pixelRatio);
|
||||
const targetHeight = Math.round(height * pixelRatio);
|
||||
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
}
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
if (!finitePlane(plane)) {
|
||||
context.fillStyle = "#0a1512";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = "#ff7b73";
|
||||
context.font = "700 14px Inter, system-ui, sans-serif";
|
||||
context.fillText("표시 평면 범위를 확인하세요.", 20, 30);
|
||||
return;
|
||||
}
|
||||
drawGrid(context, width, height, plane);
|
||||
if (image) {
|
||||
context.globalAlpha = 0.7;
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
context.globalAlpha = 1;
|
||||
context.fillStyle = "rgba(6,15,12,.12)";
|
||||
context.fillRect(0, 0, width, height);
|
||||
}
|
||||
scene.sources.forEach((source, index) => {
|
||||
drawSource(context, source, index, source.id === selectedSourceId, plane, width, height);
|
||||
});
|
||||
emptyElement.classList.toggle("is-hidden", Boolean(image));
|
||||
badgeElement.textContent =
|
||||
`x ${plane.xMin.toFixed(2)}…${plane.xMax.toFixed(2)} m · ` +
|
||||
`y ${plane.yMin.toFixed(2)}…${plane.yMax.toFixed(2)} m`;
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (!current || !current.selectedSourceId || !finitePlane(current.plane)) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const xRatio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const yRatio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||||
onMove(pixelToCoordinate({ x: xRatio * rect.width, y: yRatio * rect.height }, current.plane, rect.width, rect.height));
|
||||
});
|
||||
|
||||
new ResizeObserver(() => {
|
||||
if (current) render(current.scene, current.image, current.plane, current.selectedSourceId);
|
||||
}).observe(canvas);
|
||||
|
||||
return { render };
|
||||
}
|
||||
+143
-11
@@ -6,27 +6,159 @@
|
||||
<meta name="description" content="micarrsim 음향 장면 및 ground truth 생성기" />
|
||||
<title>Dataset Builder · micarrsim</title>
|
||||
<link rel="stylesheet" href="/packages/ui/app-shell.css" />
|
||||
<link rel="stylesheet" href="/apps/dataset-builder/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="app-shell builder-shell">
|
||||
<header class="app-header builder-header">
|
||||
<a class="app-brand" href="/">
|
||||
<strong>micarrsim</strong>
|
||||
<span>Dataset Builder</span>
|
||||
</a>
|
||||
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||
<div class="header-actions">
|
||||
<button class="button button--ghost" id="preview-button" type="button">▶ 미리 듣기</button>
|
||||
<button class="button button--ghost" id="wav-button" type="button" title="공간정보 없이 모든 음원을 합친 mono 확인용 WAV입니다.">Preview WAV 저장</button>
|
||||
<button class="button button--primary" id="export-button" type="button">Scene JSON 저장</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<section class="empty-state">
|
||||
<p class="eyebrow">Scene authoring</p>
|
||||
<h1>음향 장면 만들기</h1>
|
||||
<p>
|
||||
이 화면에서 기기 이미지에 음원을 배치하고 ambient audio를 혼합해 scene manifest와
|
||||
ground truth를 만들게 됩니다. 공통 데이터 계약을 확정한 뒤 편집기를 연결합니다.
|
||||
</p>
|
||||
|
||||
<main class="builder-layout">
|
||||
<section class="scene-workspace" aria-label="장면 편집 화면">
|
||||
<div class="workspace-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Ground-truth canvas</p>
|
||||
<h1 id="workspace-title">음향 장면</h1>
|
||||
</div>
|
||||
<div class="workspace-tools">
|
||||
<label class="button button--ghost file-button">
|
||||
배경 이미지
|
||||
<input id="image-input" type="file" accept="image/*" />
|
||||
</label>
|
||||
<button class="button button--danger is-hidden" id="remove-image-button" type="button">이미지 해제</button>
|
||||
<label class="button button--ghost file-button">
|
||||
오디오 추가
|
||||
<input id="audio-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
|
||||
</label>
|
||||
<label class="button button--ghost file-button">
|
||||
Scene 불러오기
|
||||
<input id="scene-input" type="file" accept="application/json,.json" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-card">
|
||||
<canvas id="scene-canvas" aria-label="이미지 평면과 음원 ground truth"></canvas>
|
||||
<div class="canvas-empty" id="canvas-empty">
|
||||
<strong>이미지를 추가하거나 grid를 그대로 사용하세요.</strong>
|
||||
<span>선택한 음원을 옮기려면 canvas를 클릭하세요.</span>
|
||||
</div>
|
||||
<div class="canvas-badge" id="canvas-badge">x −0.60…0.60 m · y −0.45…0.45 m</div>
|
||||
</div>
|
||||
|
||||
<div class="source-strip-heading">
|
||||
<div>
|
||||
<h2>음원 ground truth</h2>
|
||||
<p>카드를 선택한 뒤 canvas를 클릭해 위치를 지정합니다.</p>
|
||||
</div>
|
||||
<button class="button button--primary" id="add-source-button" type="button">+ 음원 추가</button>
|
||||
</div>
|
||||
<div class="source-strip" id="source-list" aria-label="음원 목록"></div>
|
||||
|
||||
<section class="asset-shelf">
|
||||
<div>
|
||||
<h2>로컬 assets</h2>
|
||||
<p>원본 파일은 업로드되지 않습니다. Camera에서 JSON과 함께 같은 파일을 선택하세요.</p>
|
||||
</div>
|
||||
<div class="asset-list" id="asset-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="control-panel" aria-label="장면 파라미터">
|
||||
<div class="runtime-line">
|
||||
<span class="status-chip status-chip--neutral" data-runtime-status>런타임 확인 중…</span>
|
||||
<span class="save-state" id="save-state">기본 장면</span>
|
||||
</div>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>01</span><h2>장면</h2></div>
|
||||
<label class="field field--wide">
|
||||
<span>이름</span>
|
||||
<input id="scene-name" type="text" maxlength="120" />
|
||||
</label>
|
||||
<label class="field field--wide">
|
||||
<span>Scene ID</span>
|
||||
<input id="scene-id" type="text" maxlength="120" pattern="[a-z0-9][a-z0-9._-]*" />
|
||||
</label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>길이 (s)</span><input id="scene-duration" type="number" min="0.05" max="300" step="0.05" /></label>
|
||||
<label class="field"><span>Sample rate</span><input id="sample-rate" type="number" min="8000" max="192000" step="1" /></label>
|
||||
<label class="field"><span>음속 (m/s)</span><input id="speed-of-sound" type="number" min="250" max="400" step="0.1" /></label>
|
||||
<label class="field"><span>Seed</span><input id="scene-seed" type="number" min="0" max="4294967295" step="1" /></label>
|
||||
<label class="field"><span>Mix 정규화</span><select id="mix-normalization"><option value="none">없음</option><option value="peak">Peak</option></select></label>
|
||||
<label class="field"><span>Target peak</span><input id="target-peak" type="number" min="0.01" max="1" step="0.01" /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>02</span><h2>표시 평면</h2></div>
|
||||
<div class="field-grid field-grid--compact">
|
||||
<label class="field"><span>x min</span><input id="plane-x-min" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>x max</span><input id="plane-x-max" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>y min</span><input id="plane-y-min" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>y max</span><input id="plane-y-max" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>plane z (m)</span><input id="plane-z" type="number" min="0.01" step="0.1" /></label>
|
||||
</div>
|
||||
<p class="section-note">x/y는 편집 화면 범위이며, 배경 이미지가 있을 때 image plane으로 JSON에 저장됩니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="control-section" id="source-editor-section">
|
||||
<div class="section-title"><span>03</span><h2>선택 음원</h2></div>
|
||||
<div id="no-source-message" class="inline-note">음원을 선택하세요.</div>
|
||||
<div id="source-fields">
|
||||
<label class="field field--wide"><span>이름</span><input id="source-name" type="text" maxlength="120" /></label>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label class="field"><span>x (m)</span><input id="source-x" type="number" step="0.01" /></label>
|
||||
<label class="field"><span>y (m)</span><input id="source-y" type="number" step="0.01" /></label>
|
||||
<label class="field"><span>거리 z (m)</span><input id="source-z" type="number" min="0.01" step="0.1" /></label>
|
||||
</div>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label class="field"><span>Gain (dB)</span><input id="source-gain" type="number" min="-120" max="24" step="1" /></label>
|
||||
<label class="field"><span>시작 (s)</span><input id="source-start" type="number" min="0" step="0.01" /></label>
|
||||
<label class="field"><span>길이 (s)</span><input id="source-duration" type="number" min="0.01" step="0.01" /></label>
|
||||
</div>
|
||||
<label class="field field--wide"><span>신호 종류</span><select id="source-type"><option value="tone">Tone</option><option value="noise">Noise</option><option value="asset">Audio asset</option></select></label>
|
||||
<div id="tone-fields"><label class="field field--wide"><span>주파수 (Hz)</span><input id="tone-frequency" type="number" min="1" step="1" /></label></div>
|
||||
<div id="noise-fields"><label class="field field--wide"><span>Noise color</span><select id="noise-color"><option value="white">White</option><option value="pink">Pink</option></select></label></div>
|
||||
<div id="asset-fields">
|
||||
<label class="field field--wide"><span>Audio asset</span><select id="source-asset"></select></label>
|
||||
<div class="field-grid">
|
||||
<label class="field"><span>Channel</span><input id="source-channel" type="number" min="0" max="255" step="1" /></label>
|
||||
<label class="check-field"><input id="source-loop" type="checkbox" /><span>짧은 파일 반복</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button button--danger button--full" id="remove-source-button" type="button">선택 음원 삭제</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="section-title"><span>04</span><h2>Ambient</h2></div>
|
||||
<label class="field field--wide"><span>종류</span><select id="ambient-type"><option value="none">없음</option><option value="white">White noise</option><option value="pink">Pink noise</option><option value="asset">Audio asset</option></select></label>
|
||||
<label class="field field--wide" id="ambient-asset-field"><span>Audio asset</span><select id="ambient-asset"></select></label>
|
||||
<div class="field-grid" id="ambient-asset-options">
|
||||
<label class="field"><span>Channel</span><input id="ambient-channel" type="number" min="0" max="255" step="1" /></label>
|
||||
<label class="check-field"><input id="ambient-loop" type="checkbox" /><span>짧은 파일 반복</span></label>
|
||||
</div>
|
||||
<label class="field field--wide"><span>Gain (dB)</span><input id="ambient-gain" type="number" min="-120" max="24" step="1" /></label>
|
||||
</section>
|
||||
|
||||
<section class="validation-card" aria-live="polite">
|
||||
<div class="validation-dot" id="validation-dot"></div>
|
||||
<div><strong id="validation-title">검증 대기</strong><p id="validation-message">장면을 준비하고 있습니다.</p></div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/packages/ui/runtime-status.js"></script>
|
||||
<script type="module" src="/apps/dataset-builder/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
||||
import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
|
||||
import { downloadBlob, downloadJson, readJsonFile, sha256File } from "/packages/contracts/io.js";
|
||||
import { validateScene } from "/packages/contracts/validation.js";
|
||||
import { createAudioRuntime } from "./audio-runtime.js";
|
||||
import { createSceneCanvas } from "./canvas-view.js";
|
||||
import {
|
||||
DEFAULT_VIEW_PLANE,
|
||||
SOURCE_COLORS,
|
||||
assetDescriptorForFile,
|
||||
changeSignalType,
|
||||
createSceneId,
|
||||
createSource,
|
||||
describeSignal,
|
||||
isAssetReferenced,
|
||||
matchAssetDescriptor,
|
||||
prepareSceneForEditing,
|
||||
referencedAssetIds,
|
||||
safeDownloadStem,
|
||||
} from "./model.js";
|
||||
|
||||
const byId = (id) => {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) throw new Error(`필수 UI를 찾을 수 없습니다: #${id}`);
|
||||
return element;
|
||||
};
|
||||
|
||||
const elements = Object.fromEntries(
|
||||
[
|
||||
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-input", "scene-input",
|
||||
"scene-canvas", "canvas-empty", "canvas-badge", "workspace-title", "source-list", "add-source-button",
|
||||
"asset-list", "scene-name", "scene-id", "scene-duration", "sample-rate", "speed-of-sound", "scene-seed",
|
||||
"mix-normalization", "target-peak", "plane-x-min", "plane-x-max", "plane-y-min", "plane-y-max",
|
||||
"plane-z", "no-source-message", "source-fields", "source-name", "source-x", "source-y", "source-z",
|
||||
"source-gain", "source-start", "source-duration", "source-type", "tone-fields", "tone-frequency",
|
||||
"noise-fields", "noise-color", "asset-fields", "source-asset", "source-channel", "source-loop",
|
||||
"remove-source-button", "ambient-type", "ambient-asset-field", "ambient-asset-options", "ambient-asset",
|
||||
"ambient-channel", "ambient-loop", "ambient-gain",
|
||||
"validation-dot", "validation-title", "validation-message", "save-state",
|
||||
].map((id) => [id, byId(id)]),
|
||||
);
|
||||
elements.validationCard = document.querySelector(".validation-card");
|
||||
|
||||
const state = {
|
||||
scene: null,
|
||||
selectedSourceId: null,
|
||||
viewPlane: { ...DEFAULT_VIEW_PLANE },
|
||||
imageElement: null,
|
||||
imageUrl: null,
|
||||
assetFiles: new Map(),
|
||||
decodedAudio: new Map(),
|
||||
assetErrors: new Map(),
|
||||
assetEpoch: 0,
|
||||
imageJobRevision: 0,
|
||||
dirty: false,
|
||||
validationRevision: 0,
|
||||
mixActionActive: false,
|
||||
mixActionCancelled: false,
|
||||
mixAbortController: null,
|
||||
};
|
||||
|
||||
const MAX_LOCAL_ASSET_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_BOUND_ASSET_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
const audioRuntime = createAudioRuntime();
|
||||
const canvasView = createSceneCanvas(
|
||||
elements["scene-canvas"],
|
||||
elements["canvas-empty"],
|
||||
elements["canvas-badge"],
|
||||
({ x, y }) => {
|
||||
const source = selectedSource();
|
||||
if (!source) return;
|
||||
source.position[0] = Number(x.toFixed(4));
|
||||
source.position[1] = Number(y.toFixed(4));
|
||||
syncSourceEditor();
|
||||
markChanged("위치 변경됨");
|
||||
renderSourceCards();
|
||||
renderCanvas();
|
||||
},
|
||||
);
|
||||
|
||||
function selectedSource() {
|
||||
return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null;
|
||||
}
|
||||
|
||||
function numberFrom(element) {
|
||||
return element.valueAsNumber;
|
||||
}
|
||||
|
||||
function setNumber(element, value) {
|
||||
element.value = Number.isFinite(value) ? String(value) : "";
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
elements["save-state"].textContent = message;
|
||||
}
|
||||
|
||||
function cancelPendingMix() {
|
||||
if (state.mixActionActive) {
|
||||
state.mixActionCancelled = true;
|
||||
state.mixAbortController?.abort();
|
||||
audioRuntime.cancelMix();
|
||||
}
|
||||
}
|
||||
|
||||
function markChanged(message = "저장하지 않은 변경") {
|
||||
cancelPendingMix();
|
||||
if (audioRuntime.stopPlayback()) elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
state.dirty = true;
|
||||
setStatus(message);
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
function currentPlaneZ() {
|
||||
return state.scene.image?.plane?.z ?? selectedSource()?.position?.[2] ?? 2;
|
||||
}
|
||||
|
||||
function syncImagePlane() {
|
||||
if (!state.scene.image) return;
|
||||
Object.assign(state.scene.image.plane, state.viewPlane, { z: numberFrom(elements["plane-z"]) });
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
if (!state.scene) return;
|
||||
canvasView.render(state.scene, state.imageElement, state.viewPlane, state.selectedSourceId);
|
||||
}
|
||||
|
||||
function renderSourceCards() {
|
||||
const list = elements["source-list"];
|
||||
list.replaceChildren();
|
||||
state.scene.sources.forEach((source, index) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "source-card";
|
||||
button.classList.toggle("is-selected", source.id === state.selectedSourceId);
|
||||
button.style.setProperty("--source-color", SOURCE_COLORS[index % SOURCE_COLORS.length]);
|
||||
const heading = document.createElement("span");
|
||||
heading.className = "source-card__heading";
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "source-card__dot";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = source.name;
|
||||
heading.append(dot, name);
|
||||
const signal = document.createElement("small");
|
||||
signal.textContent = describeSignal(source.signal);
|
||||
const position = document.createElement("small");
|
||||
position.textContent = `x ${source.position[0].toFixed(2)} · y ${source.position[1].toFixed(2)} · z ${source.position[2].toFixed(2)} m`;
|
||||
button.append(heading, signal, position);
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedSourceId = source.id;
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
renderCanvas();
|
||||
});
|
||||
list.append(button);
|
||||
});
|
||||
}
|
||||
|
||||
function fillAudioSelect(select, selectedId, placeholder) {
|
||||
select.replaceChildren();
|
||||
const audioAssets = state.scene.assets.filter((asset) => asset.kind === "audio");
|
||||
const empty = document.createElement("option");
|
||||
empty.value = "";
|
||||
empty.textContent = audioAssets.length > 0 ? placeholder : "먼저 오디오를 추가하세요";
|
||||
select.append(empty);
|
||||
for (const asset of audioAssets) {
|
||||
const option = document.createElement("option");
|
||||
option.value = asset.id;
|
||||
option.textContent = asset.fileName;
|
||||
select.append(option);
|
||||
}
|
||||
if (selectedId && !audioAssets.some((asset) => asset.id === selectedId)) {
|
||||
const missing = document.createElement("option");
|
||||
missing.value = selectedId;
|
||||
missing.textContent = `${selectedId} (manifest 누락)`;
|
||||
select.append(missing);
|
||||
}
|
||||
select.value = selectedId ?? "";
|
||||
}
|
||||
|
||||
function syncSourceEditor() {
|
||||
const source = selectedSource();
|
||||
elements["no-source-message"].classList.toggle("is-hidden", Boolean(source));
|
||||
elements["source-fields"].classList.toggle("is-hidden", !source);
|
||||
if (!source) return;
|
||||
elements["source-name"].value = source.name;
|
||||
setNumber(elements["source-x"], source.position[0]);
|
||||
setNumber(elements["source-y"], source.position[1]);
|
||||
setNumber(elements["source-z"], source.position[2]);
|
||||
setNumber(elements["source-gain"], source.gainDb);
|
||||
setNumber(elements["source-start"], source.startTime);
|
||||
setNumber(elements["source-duration"], source.duration);
|
||||
elements["source-type"].value = source.signal.type;
|
||||
elements["tone-fields"].classList.toggle("is-hidden", source.signal.type !== "tone");
|
||||
elements["noise-fields"].classList.toggle("is-hidden", source.signal.type !== "noise");
|
||||
elements["asset-fields"].classList.toggle("is-hidden", source.signal.type !== "asset");
|
||||
if (source.signal.type === "tone") setNumber(elements["tone-frequency"], source.signal.frequency);
|
||||
if (source.signal.type === "noise") elements["noise-color"].value = source.signal.color;
|
||||
if (source.signal.type === "asset") {
|
||||
fillAudioSelect(elements["source-asset"], source.signal.assetId, "Audio asset 선택");
|
||||
setNumber(elements["source-channel"], source.signal.channel ?? 0);
|
||||
elements["source-loop"].checked = source.signal.loop ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
function ambientEntry() {
|
||||
return state.scene.ambient[0] ?? null;
|
||||
}
|
||||
|
||||
function syncAmbientEditor() {
|
||||
const ambient = ambientEntry();
|
||||
const type = ambient?.signal?.type === "noise" ? ambient.signal.color : ambient?.signal?.type ?? "none";
|
||||
elements["ambient-type"].value = type;
|
||||
elements["ambient-asset-field"].classList.toggle("is-hidden", type !== "asset");
|
||||
elements["ambient-asset-options"].classList.toggle("is-hidden", type !== "asset");
|
||||
fillAudioSelect(elements["ambient-asset"], type === "asset" ? ambient.signal.assetId : "", "Audio asset 선택");
|
||||
setNumber(elements["ambient-channel"], type === "asset" ? ambient.signal.channel ?? 0 : 0);
|
||||
elements["ambient-loop"].checked = type === "asset" ? ambient.signal.loop ?? false : false;
|
||||
setNumber(elements["ambient-gain"], ambient?.gainDb ?? -36);
|
||||
}
|
||||
|
||||
function renderAssetList() {
|
||||
const list = elements["asset-list"];
|
||||
list.replaceChildren();
|
||||
if (state.scene.assets.length === 0) {
|
||||
const empty = document.createElement("span");
|
||||
empty.className = "inline-note";
|
||||
empty.textContent = "아직 asset이 없습니다.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const asset of state.scene.assets) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "asset-chip";
|
||||
const label = document.createElement("span");
|
||||
label.className = "asset-chip__label";
|
||||
label.textContent = `${asset.kind} · ${asset.fileName}`;
|
||||
label.title = `${asset.id}${asset.sha256 ? ` · ${asset.sha256}` : ""}`;
|
||||
const availability = document.createElement("span");
|
||||
availability.className = "asset-chip__state";
|
||||
availability.textContent = state.assetErrors.has(asset.id)
|
||||
? "오류"
|
||||
: state.assetFiles.has(asset.id) ? "연결됨" : "파일 필요";
|
||||
if (state.assetErrors.has(asset.id)) availability.title = state.assetErrors.get(asset.id);
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "asset-chip__remove";
|
||||
remove.textContent = "×";
|
||||
const referenced = isAssetReferenced(state.scene, asset.id);
|
||||
remove.disabled = referenced;
|
||||
remove.title = referenced ? "먼저 이 asset을 사용하는 image/source/ambient를 변경하세요." : `${asset.fileName} 제거`;
|
||||
remove.addEventListener("click", () => removeAsset(asset.id));
|
||||
chip.append(label, availability, remove);
|
||||
list.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSceneControls() {
|
||||
const scene = state.scene;
|
||||
elements["scene-name"].value = scene.name;
|
||||
elements["scene-id"].value = scene.id;
|
||||
setNumber(elements["scene-duration"], scene.duration);
|
||||
elements["sample-rate"].value = String(scene.sampleRate);
|
||||
setNumber(elements["speed-of-sound"], scene.speedOfSound);
|
||||
setNumber(elements["scene-seed"], scene.seed);
|
||||
elements["mix-normalization"].value = scene.mix.normalization;
|
||||
setNumber(elements["target-peak"], scene.mix.targetPeak);
|
||||
setNumber(elements["plane-x-min"], state.viewPlane.xMin);
|
||||
setNumber(elements["plane-x-max"], state.viewPlane.xMax);
|
||||
setNumber(elements["plane-y-min"], state.viewPlane.yMin);
|
||||
setNumber(elements["plane-y-max"], state.viewPlane.yMax);
|
||||
setNumber(elements["plane-z"], currentPlaneZ());
|
||||
elements["plane-z"].disabled = !scene.image;
|
||||
elements["remove-image-button"].classList.toggle("is-hidden", !scene.image);
|
||||
elements["target-peak"].disabled = scene.mix.normalization !== "peak";
|
||||
elements["workspace-title"].textContent = scene.name;
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
syncSceneControls();
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
syncAmbientEditor();
|
||||
renderAssetList();
|
||||
renderCanvas();
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
function requiredAssetIds({ audioOnly = false } = {}) {
|
||||
const referenced = referencedAssetIds(state.scene);
|
||||
if (!audioOnly) return [...referenced];
|
||||
const assetsById = new Map(state.scene.assets.map((asset) => [asset.id, asset]));
|
||||
return [...referenced].filter((id) => assetsById.get(id)?.kind === "audio");
|
||||
}
|
||||
|
||||
function unavailableAssetIds(options) {
|
||||
return requiredAssetIds(options).filter(
|
||||
(id) => !state.assetFiles.has(id) || state.assetErrors.has(id),
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleValidation() {
|
||||
const revision = ++state.validationRevision;
|
||||
window.clearTimeout(state.validationTimer);
|
||||
document.documentElement.dataset.sceneValid = "pending";
|
||||
document.documentElement.dataset.assetsReady = "pending";
|
||||
if (!state.mixActionActive) {
|
||||
elements["preview-button"].disabled = true;
|
||||
elements["wav-button"].disabled = true;
|
||||
elements["export-button"].disabled = true;
|
||||
}
|
||||
state.validationTimer = window.setTimeout(async () => {
|
||||
const issues = await validateScene(state.scene);
|
||||
if (revision !== state.validationRevision) return;
|
||||
const missingAll = unavailableAssetIds();
|
||||
const missingAudio = unavailableAssetIds({ audioOnly: true });
|
||||
const valid = issues.length === 0;
|
||||
document.documentElement.dataset.sceneValid = String(valid);
|
||||
document.documentElement.dataset.assetsReady = String(missingAll.length === 0);
|
||||
elements.validationCard.classList.toggle("is-valid", valid && missingAll.length === 0);
|
||||
elements.validationCard.classList.toggle("is-invalid", !valid || missingAll.length > 0);
|
||||
if (!valid) {
|
||||
elements["validation-title"].textContent = `${issues.length}개 항목을 확인하세요`;
|
||||
elements["validation-message"].textContent = issues
|
||||
.slice(0, 3)
|
||||
.map(({ path, message }) => `${path} ${message}`)
|
||||
.join(" · ");
|
||||
} else if (missingAll.length > 0) {
|
||||
elements["validation-title"].textContent = `원본 asset ${missingAll.length}개 필요`;
|
||||
elements["validation-message"].textContent = missingAll.join(", ");
|
||||
} else {
|
||||
elements["validation-title"].textContent = "저장 가능한 장면";
|
||||
elements["validation-message"].textContent = `${state.scene.sources.length}개 음원 · ${state.scene.sampleRate.toLocaleString()} Hz · ${state.scene.duration} s`;
|
||||
}
|
||||
if (!state.mixActionActive) {
|
||||
elements["preview-button"].disabled = !valid || missingAudio.length > 0;
|
||||
elements["wav-button"].disabled = !valid || missingAudio.length > 0;
|
||||
elements["export-button"].disabled = !valid || missingAll.length > 0;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function removeAsset(assetId) {
|
||||
const asset = state.scene.assets.find((candidate) => candidate.id === assetId);
|
||||
if (!asset) return;
|
||||
if (isAssetReferenced(state.scene, assetId)) {
|
||||
setStatus(`${asset.fileName}은 장면에서 사용 중입니다.`);
|
||||
return;
|
||||
}
|
||||
state.assetEpoch += 1;
|
||||
state.scene.assets = state.scene.assets.filter((candidate) => candidate.id !== assetId);
|
||||
state.assetFiles.delete(assetId);
|
||||
state.decodedAudio.delete(assetId);
|
||||
state.assetErrors.delete(assetId);
|
||||
renderAssetList();
|
||||
syncSourceEditor();
|
||||
syncAmbientEditor();
|
||||
markChanged("asset 제거됨");
|
||||
}
|
||||
|
||||
function findDescriptorForFile(file, kind, hash) {
|
||||
return matchAssetDescriptor(state.scene.assets, new Set(state.assetFiles.keys()), file.name, kind, hash);
|
||||
}
|
||||
|
||||
async function decodeImageFile(file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
if (image.naturalWidth > 32768 || image.naturalHeight > 32768) {
|
||||
throw new Error("이미지는 가로·세로 32,768 px 이하여야 합니다.");
|
||||
}
|
||||
return { image, url };
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFile(file, kind, expectedEpoch = state.assetEpoch) {
|
||||
const epoch = expectedEpoch;
|
||||
if (file.size > MAX_LOCAL_ASSET_BYTES) {
|
||||
throw new Error(`${file.name}은 ${(MAX_LOCAL_ASSET_BYTES / 1024 ** 2).toFixed(0)} MiB 이하여야 합니다.`);
|
||||
}
|
||||
setStatus(`${file.name} SHA-256 계산 중…`);
|
||||
const hash = await sha256File(file);
|
||||
if (epoch !== state.assetEpoch) return null;
|
||||
const match = findDescriptorForFile(file, kind, hash);
|
||||
let descriptor = match.descriptor;
|
||||
if (!descriptor) {
|
||||
descriptor = assetDescriptorForFile(file, kind, new Set(state.scene.assets.map((asset) => asset.id)));
|
||||
state.scene.assets.push(descriptor);
|
||||
}
|
||||
const currentFileBytes = state.assetFiles.get(descriptor.id)?.size ?? 0;
|
||||
const boundBytes = [...state.assetFiles.values()].reduce((sum, candidate) => sum + candidate.size, 0);
|
||||
if (boundBytes - currentFileBytes + file.size > MAX_BOUND_ASSET_BYTES) {
|
||||
if (!match.descriptor) state.scene.assets = state.scene.assets.filter((asset) => asset !== descriptor);
|
||||
throw new Error(`연결한 asset 파일 합계는 ${(MAX_BOUND_ASSET_BYTES / 1024 ** 2).toFixed(0)} MiB 이하여야 합니다.`);
|
||||
}
|
||||
descriptor.sha256 = hash;
|
||||
if (file.type) descriptor.mimeType = file.type;
|
||||
state.assetFiles.set(descriptor.id, file);
|
||||
state.decodedAudio.delete(descriptor.id);
|
||||
state.assetErrors.delete(descriptor.id);
|
||||
if (match.conflict) {
|
||||
setStatus(`같은 이름의 다른 파일을 ${descriptor.id}로 분리했습니다.`);
|
||||
} else {
|
||||
setStatus(`${file.name} 연결됨 · SHA ${hash.slice(0, 10)}…`);
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function decodedAudioBytes(decoded) {
|
||||
return decoded.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
|
||||
}
|
||||
|
||||
function abortIfMixChanged(expectedEpoch) {
|
||||
if (state.mixActionCancelled || expectedEpoch !== state.assetEpoch) {
|
||||
throw new DOMException("장면 변경으로 mix 생성을 취소했습니다.", "AbortError");
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDecodedAudio(expectedEpoch, signal) {
|
||||
const buffers = new Map();
|
||||
const requiredIds = new Set(requiredAssetIds({ audioOnly: true }));
|
||||
for (const assetId of state.decodedAudio.keys()) {
|
||||
if (!requiredIds.has(assetId)) state.decodedAudio.delete(assetId);
|
||||
}
|
||||
let retainedBytes = 0;
|
||||
for (const [assetId, decoded] of state.decodedAudio) {
|
||||
if (requiredIds.has(assetId) && decoded.sampleRate === state.scene.sampleRate) retainedBytes += decodedAudioBytes(decoded);
|
||||
}
|
||||
for (const assetId of requiredIds) {
|
||||
abortIfMixChanged(expectedEpoch);
|
||||
const file = state.assetFiles.get(assetId);
|
||||
const asset = state.scene.assets.find((candidate) => candidate.id === assetId);
|
||||
if (!file || !asset) throw new Error(`오디오 원본 파일이 필요합니다: ${assetId}`);
|
||||
let decoded = state.decodedAudio.get(assetId);
|
||||
if (!decoded || decoded.sampleRate !== state.scene.sampleRate) {
|
||||
setStatus(`${asset.fileName} ${state.scene.sampleRate.toLocaleString()} Hz decode 중…`);
|
||||
try {
|
||||
const remainingBytes = MAX_DECODED_AUDIO_BYTES - retainedBytes;
|
||||
if (remainingBytes <= 0) throw new Error("참조 오디오의 누적 decode 메모리 한도를 넘습니다.");
|
||||
decoded = await decodeAudioFile(file, state.scene.sampleRate, remainingBytes, signal);
|
||||
abortIfMixChanged(expectedEpoch);
|
||||
state.decodedAudio.set(assetId, decoded);
|
||||
retainedBytes += decodedAudioBytes(decoded);
|
||||
state.assetErrors.delete(assetId);
|
||||
} catch (error) {
|
||||
if (expectedEpoch === state.assetEpoch && !state.mixActionCancelled) {
|
||||
state.assetErrors.set(assetId, error instanceof Error ? error.message : String(error));
|
||||
renderAssetList();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
buffers.set(assetId, decoded);
|
||||
}
|
||||
return buffers;
|
||||
}
|
||||
|
||||
function formatMixStats(result) {
|
||||
const values = [
|
||||
`입력 peak ${result.preNormalizationPeak.toFixed(4)}`,
|
||||
`scale ${result.appliedScale.toFixed(4)}`,
|
||||
`출력 peak ${result.peak.toFixed(4)}`,
|
||||
];
|
||||
if (result.clippedSampleCount > 0) values.push(`clipping ${result.clippedSampleCount.toLocaleString()} samples`);
|
||||
return values.join(" · ");
|
||||
}
|
||||
|
||||
async function runMixAction(button, busyLabel, onResult) {
|
||||
if (state.mixActionActive) {
|
||||
cancelPendingMix();
|
||||
setStatus("확인용 mix 생성을 취소했습니다.");
|
||||
return;
|
||||
}
|
||||
state.mixActionActive = true;
|
||||
state.mixActionCancelled = false;
|
||||
state.mixAbortController = new AbortController();
|
||||
const buttons = [elements["preview-button"], elements["wav-button"], elements["export-button"]];
|
||||
const expectedEpoch = state.assetEpoch;
|
||||
const originalText = button.textContent;
|
||||
buttons.forEach((candidate) => { candidate.disabled = candidate !== button; });
|
||||
button.textContent = "취소";
|
||||
try {
|
||||
setStatus(busyLabel);
|
||||
const assetBuffers = await ensureDecodedAudio(expectedEpoch, state.mixAbortController.signal);
|
||||
abortIfMixChanged(expectedEpoch);
|
||||
const result = await audioRuntime.renderMix(structuredClone(state.scene), assetBuffers);
|
||||
await onResult(result);
|
||||
setStatus(formatMixStats(result));
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
state.mixActionActive = false;
|
||||
state.mixAbortController = null;
|
||||
button.textContent = originalText;
|
||||
scheduleValidation();
|
||||
}
|
||||
}
|
||||
|
||||
function bindInput(id, callback, eventName = "input") {
|
||||
elements[id].addEventListener(eventName, () => {
|
||||
callback(elements[id]);
|
||||
markChanged();
|
||||
});
|
||||
}
|
||||
|
||||
function bindSceneControls() {
|
||||
bindInput("scene-name", (input) => {
|
||||
state.scene.name = input.value;
|
||||
elements["workspace-title"].textContent = input.value || "이름 없는 장면";
|
||||
});
|
||||
bindInput("scene-id", (input) => { state.scene.id = input.value; });
|
||||
bindInput("scene-duration", (input) => { state.scene.duration = numberFrom(input); });
|
||||
bindInput("sample-rate", (input) => {
|
||||
state.scene.sampleRate = Number(input.value);
|
||||
state.decodedAudio.clear();
|
||||
for (const asset of state.scene.assets) {
|
||||
if (asset.kind === "audio") state.assetErrors.delete(asset.id);
|
||||
}
|
||||
renderAssetList();
|
||||
}, "change");
|
||||
bindInput("speed-of-sound", (input) => { state.scene.speedOfSound = numberFrom(input); });
|
||||
bindInput("scene-seed", (input) => { state.scene.seed = numberFrom(input); });
|
||||
bindInput("mix-normalization", (input) => {
|
||||
state.scene.mix.normalization = input.value;
|
||||
elements["target-peak"].disabled = input.value !== "peak";
|
||||
}, "change");
|
||||
bindInput("target-peak", (input) => { state.scene.mix.targetPeak = numberFrom(input); });
|
||||
|
||||
for (const [id, key] of [
|
||||
["plane-x-min", "xMin"], ["plane-x-max", "xMax"],
|
||||
["plane-y-min", "yMin"], ["plane-y-max", "yMax"],
|
||||
]) {
|
||||
bindInput(id, (input) => {
|
||||
state.viewPlane[key] = numberFrom(input);
|
||||
syncImagePlane();
|
||||
renderCanvas();
|
||||
});
|
||||
}
|
||||
bindInput("plane-z", () => { syncImagePlane(); });
|
||||
}
|
||||
|
||||
function bindSourceControls() {
|
||||
elements["add-source-button"].addEventListener("click", () => {
|
||||
if (state.scene.sources.length >= 32) {
|
||||
setStatus("음원은 최대 32개까지 추가할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
const source = createSource(state.scene);
|
||||
state.scene.sources.push(source);
|
||||
state.selectedSourceId = source.id;
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
renderCanvas();
|
||||
markChanged("음원 추가됨");
|
||||
});
|
||||
elements["remove-source-button"].addEventListener("click", () => {
|
||||
if (state.scene.sources.length <= 1) {
|
||||
setStatus("장면에는 음원이 하나 이상 필요합니다.");
|
||||
return;
|
||||
}
|
||||
state.scene.sources = state.scene.sources.filter((source) => source.id !== state.selectedSourceId);
|
||||
state.selectedSourceId = state.scene.sources[0].id;
|
||||
renderSourceCards();
|
||||
syncSourceEditor();
|
||||
renderCanvas();
|
||||
markChanged("음원 삭제됨");
|
||||
});
|
||||
|
||||
bindInput("source-name", (input) => { selectedSource().name = input.value; renderSourceCards(); });
|
||||
for (const [id, index] of [["source-x", 0], ["source-y", 1], ["source-z", 2]]) {
|
||||
bindInput(id, (input) => {
|
||||
selectedSource().position[index] = numberFrom(input);
|
||||
renderSourceCards();
|
||||
renderCanvas();
|
||||
});
|
||||
}
|
||||
bindInput("source-gain", (input) => { selectedSource().gainDb = numberFrom(input); });
|
||||
bindInput("source-start", (input) => { selectedSource().startTime = numberFrom(input); });
|
||||
bindInput("source-duration", (input) => { selectedSource().duration = numberFrom(input); });
|
||||
bindInput("source-type", (input) => {
|
||||
const firstAudioId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
|
||||
changeSignalType(selectedSource(), input.value, firstAudioId);
|
||||
syncSourceEditor();
|
||||
renderSourceCards();
|
||||
}, "change");
|
||||
bindInput("tone-frequency", (input) => { selectedSource().signal.frequency = numberFrom(input); renderSourceCards(); });
|
||||
bindInput("noise-color", (input) => { selectedSource().signal.color = input.value; renderSourceCards(); }, "change");
|
||||
bindInput("source-asset", (input) => { selectedSource().signal.assetId = input.value; renderSourceCards(); }, "change");
|
||||
bindInput("source-channel", (input) => { selectedSource().signal.channel = numberFrom(input); });
|
||||
bindInput("source-loop", (input) => { selectedSource().signal.loop = input.checked; }, "change");
|
||||
}
|
||||
|
||||
function createAmbientSignal(type) {
|
||||
if (type === "white" || type === "pink") return { type: "noise", color: type, seedOffset: 0 };
|
||||
const assetId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
|
||||
return { type: "asset", assetId, channel: 0, loop: true };
|
||||
}
|
||||
|
||||
function bindAmbientControls() {
|
||||
bindInput("ambient-type", (input) => {
|
||||
if (input.value === "none") {
|
||||
state.scene.ambient = [];
|
||||
} else {
|
||||
const current = ambientEntry();
|
||||
const rest = state.scene.ambient.slice(1);
|
||||
const first = {
|
||||
id: current?.id ?? "ambient-1",
|
||||
gainDb: current?.gainDb ?? -36,
|
||||
signal: createAmbientSignal(input.value),
|
||||
};
|
||||
state.scene.ambient = [first, ...rest];
|
||||
}
|
||||
syncAmbientEditor();
|
||||
}, "change");
|
||||
bindInput("ambient-gain", (input) => {
|
||||
if (ambientEntry()) ambientEntry().gainDb = numberFrom(input);
|
||||
});
|
||||
bindInput("ambient-asset", (input) => {
|
||||
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.assetId = input.value;
|
||||
}, "change");
|
||||
bindInput("ambient-channel", (input) => {
|
||||
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.channel = numberFrom(input);
|
||||
});
|
||||
bindInput("ambient-loop", (input) => {
|
||||
if (ambientEntry()?.signal.type === "asset") ambientEntry().signal.loop = input.checked;
|
||||
}, "change");
|
||||
}
|
||||
|
||||
async function handleImageFile(file, imageJobRevision, epoch) {
|
||||
const descriptor = await attachFile(file, "image", epoch);
|
||||
if (!descriptor || imageJobRevision !== state.imageJobRevision) return;
|
||||
try {
|
||||
const loaded = await decodeImageFile(file);
|
||||
if (epoch !== state.assetEpoch || imageJobRevision !== state.imageJobRevision) {
|
||||
URL.revokeObjectURL(loaded.url);
|
||||
return;
|
||||
}
|
||||
const { image } = loaded;
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
state.imageUrl = loaded.url;
|
||||
state.imageElement = image;
|
||||
const existingPlane = state.scene.image?.assetId === descriptor.id ? state.scene.image.plane : null;
|
||||
const plane = existingPlane
|
||||
? { ...existingPlane }
|
||||
: { ...state.viewPlane, z: currentPlaneZ() };
|
||||
state.scene.image = {
|
||||
assetId: descriptor.id,
|
||||
widthPixels: image.naturalWidth,
|
||||
heightPixels: image.naturalHeight,
|
||||
plane,
|
||||
};
|
||||
state.viewPlane = {
|
||||
xMin: plane.xMin, xMax: plane.xMax, yMin: plane.yMin, yMax: plane.yMax,
|
||||
};
|
||||
state.assetErrors.delete(descriptor.id);
|
||||
renderAll();
|
||||
markChanged("배경 이미지 연결됨");
|
||||
} catch (error) {
|
||||
if (epoch === state.assetEpoch && imageJobRevision === state.imageJobRevision) {
|
||||
state.assetErrors.set(descriptor.id, error instanceof Error ? error.message : String(error));
|
||||
renderAssetList();
|
||||
scheduleValidation();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function bindFileControls() {
|
||||
elements["image-input"].addEventListener("change", async (event) => {
|
||||
const [file] = event.target.files;
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
cancelPendingMix();
|
||||
const imageJobRevision = ++state.imageJobRevision;
|
||||
const epoch = ++state.assetEpoch;
|
||||
try {
|
||||
await handleImageFile(file, imageJobRevision, epoch);
|
||||
} catch (error) {
|
||||
if (imageJobRevision === state.imageJobRevision) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
});
|
||||
elements["remove-image-button"].addEventListener("click", () => {
|
||||
if (!state.scene.image) return;
|
||||
cancelPendingMix();
|
||||
state.imageJobRevision += 1;
|
||||
state.assetEpoch += 1;
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
state.imageUrl = null;
|
||||
state.imageElement = null;
|
||||
delete state.scene.image;
|
||||
renderAll();
|
||||
markChanged("배경 이미지 해제됨");
|
||||
});
|
||||
elements["audio-input"].addEventListener("change", async (event) => {
|
||||
const files = [...event.target.files];
|
||||
event.target.value = "";
|
||||
const epoch = state.assetEpoch;
|
||||
for (const file of files) {
|
||||
if (epoch !== state.assetEpoch) return;
|
||||
try {
|
||||
await attachFile(file, "audio", epoch);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
if (epoch !== state.assetEpoch) return;
|
||||
if (files.length > 0) {
|
||||
renderAssetList();
|
||||
syncSourceEditor();
|
||||
syncAmbientEditor();
|
||||
markChanged(`${files.length}개 오디오 처리 완료`);
|
||||
}
|
||||
});
|
||||
elements["scene-input"].addEventListener("change", async (event) => {
|
||||
const [file] = event.target.files;
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
cancelPendingMix();
|
||||
if (audioRuntime.stopPlayback()) elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
try {
|
||||
const candidate = await readJsonFile(file);
|
||||
const issues = await validateScene(candidate);
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues.slice(0, 4).map(({ path, message }) => `${path} ${message}`).join(" · "));
|
||||
}
|
||||
state.assetEpoch += 1;
|
||||
state.imageJobRevision += 1;
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
state.imageUrl = null;
|
||||
state.imageElement = null;
|
||||
state.assetFiles.clear();
|
||||
state.decodedAudio.clear();
|
||||
state.assetErrors.clear();
|
||||
state.scene = prepareSceneForEditing(candidate);
|
||||
state.selectedSourceId = state.scene.sources[0].id;
|
||||
state.viewPlane = state.scene.image
|
||||
? {
|
||||
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
|
||||
yMin: state.scene.image.plane.yMin, yMax: state.scene.image.plane.yMax,
|
||||
}
|
||||
: { ...DEFAULT_VIEW_PLANE };
|
||||
state.dirty = false;
|
||||
renderAll();
|
||||
setStatus(`Scene 불러옴 · ${requiredAssetIds().length}개 원본 asset을 다시 선택하세요.`);
|
||||
} catch (error) {
|
||||
setStatus(`불러오기 실패 · ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindOutputControls() {
|
||||
elements["preview-button"].addEventListener("click", async () => {
|
||||
if (audioRuntime.stopPlayback()) {
|
||||
elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
setStatus("미리 듣기를 중지했습니다.");
|
||||
return;
|
||||
}
|
||||
await runMixAction(elements["preview-button"], "확인용 mono mix 생성 중…", async (result) => {
|
||||
const stats = formatMixStats(result);
|
||||
await audioRuntime.play(result, () => {
|
||||
elements["preview-button"].textContent = "▶ 미리 듣기";
|
||||
setStatus(`미리 듣기 완료 · ${stats}`);
|
||||
});
|
||||
});
|
||||
if (audioRuntime.isPlaying) elements["preview-button"].textContent = "■ 중지";
|
||||
});
|
||||
elements["wav-button"].addEventListener("click", async () => {
|
||||
await runMixAction(elements["wav-button"], "확인용 WAV 생성 중…", async (result) => {
|
||||
if (result.clippedSampleCount > 0) {
|
||||
const shouldSave = window.confirm(
|
||||
`${result.clippedSampleCount.toLocaleString()}개 sample이 PCM16 범위에서 잘립니다. 그래도 저장할까요?`,
|
||||
);
|
||||
if (!shouldSave) return;
|
||||
}
|
||||
const blob = encodeMonoPcm16Wav(result.samples, result.sampleRate);
|
||||
downloadBlob(blob, `${safeDownloadStem(state.scene)}.preview-mix.wav`);
|
||||
});
|
||||
});
|
||||
elements["export-button"].addEventListener("click", async () => {
|
||||
try {
|
||||
const issues = await validateScene(state.scene);
|
||||
if (issues.length > 0) throw new Error(`${issues[0].path} ${issues[0].message}`);
|
||||
const unavailable = unavailableAssetIds();
|
||||
if (unavailable.length > 0) throw new Error(`원본 asset을 연결하세요: ${unavailable.join(", ")}`);
|
||||
downloadJson(state.scene, `${safeDownloadStem(state.scene)}.scene.json`);
|
||||
state.dirty = false;
|
||||
setStatus("Scene JSON 저장됨");
|
||||
} catch (error) {
|
||||
setStatus(`저장 실패 · ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
elements["preview-button"].disabled = true;
|
||||
elements["wav-button"].disabled = true;
|
||||
elements["export-button"].disabled = true;
|
||||
const response = await fetch("/assets/sample-scenes/single-tone.scene.json", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`기본 장면을 불러오지 못했습니다: HTTP ${response.status}`);
|
||||
state.scene = prepareSceneForEditing(await response.json());
|
||||
state.scene.id = createSceneId();
|
||||
state.selectedSourceId = state.scene.sources[0].id;
|
||||
if (state.scene.image) {
|
||||
state.viewPlane = {
|
||||
xMin: state.scene.image.plane.xMin, xMax: state.scene.image.plane.xMax,
|
||||
yMin: state.scene.image.plane.yMin, yMax: state.scene.image.plane.yMax,
|
||||
};
|
||||
}
|
||||
bindSceneControls();
|
||||
bindSourceControls();
|
||||
bindAmbientControls();
|
||||
bindFileControls();
|
||||
bindOutputControls();
|
||||
renderAll();
|
||||
setStatus("기본 장면 준비됨");
|
||||
document.documentElement.dataset.appReady = "true";
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", (event) => {
|
||||
if (!state.dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
window.addEventListener("unload", () => {
|
||||
if (state.imageUrl) URL.revokeObjectURL(state.imageUrl);
|
||||
audioRuntime.dispose();
|
||||
});
|
||||
|
||||
initialize().catch((error) => {
|
||||
document.documentElement.dataset.appReady = "false";
|
||||
elements["validation-title"].textContent = "초기화 실패";
|
||||
elements["validation-message"].textContent = error instanceof Error ? error.message : String(error);
|
||||
elements.validationCard.classList.add("is-invalid");
|
||||
setStatus("초기화 실패");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||
|
||||
self.addEventListener("message", ({ data }) => {
|
||||
try {
|
||||
const assetBuffers = new Map(data.assetBuffers);
|
||||
const result = renderSceneMix(data.scene, assetBuffers);
|
||||
self.postMessage({ ok: true, result }, [result.samples.buffer]);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
export const DEFAULT_VIEW_PLANE = Object.freeze({
|
||||
xMin: -0.6,
|
||||
xMax: 0.6,
|
||||
yMin: -0.45,
|
||||
yMax: 0.45,
|
||||
});
|
||||
|
||||
export const SOURCE_COLORS = ["#65f2bf", "#58c7e8", "#ffc56e", "#ff8f87", "#be9bff", "#8ee36b"];
|
||||
|
||||
export function prepareSceneForEditing(scene) {
|
||||
const prepared = structuredClone(scene);
|
||||
prepared.assets ??= [];
|
||||
prepared.ambient ??= [];
|
||||
prepared.mix ??= { normalization: "none", targetPeak: 0.95 };
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function createSceneId(now = Date.now(), randomValue = crypto.getRandomValues(new Uint32Array(1))[0]) {
|
||||
return `scene-${now.toString(36)}-${randomValue.toString(36)}`;
|
||||
}
|
||||
|
||||
export function uniqueId(base, existingIds) {
|
||||
const normalized = String(base)
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "asset";
|
||||
let candidate = normalized;
|
||||
let suffix = 2;
|
||||
while (existingIds.has(candidate)) {
|
||||
candidate = `${normalized}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function assetDescriptorForFile(file, kind, existingIds) {
|
||||
const baseName = file.name.replace(/\.[^.]+$/, "") || kind;
|
||||
return {
|
||||
id: uniqueId(`${kind}-${baseName}`, existingIds),
|
||||
kind,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || (kind === "image" ? "image/*" : "audio/*"),
|
||||
};
|
||||
}
|
||||
|
||||
export function matchAssetDescriptor(assets, boundAssetIds, fileName, kind, hash) {
|
||||
const candidates = assets.filter((asset) => asset.kind === kind);
|
||||
const hashMatches = candidates.filter((asset) => asset.sha256 === hash);
|
||||
const hashMatch = hashMatches.find((asset) => !boundAssetIds.has(asset.id)) ?? hashMatches[0];
|
||||
if (hashMatch) return { descriptor: hashMatch, conflict: false };
|
||||
const baseName = (path) => path.split(/[\\/]/).pop();
|
||||
const nameCandidates = candidates.filter((asset) => baseName(asset.fileName) === fileName);
|
||||
const compatible = nameCandidates.find((asset) => !asset.sha256 && !boundAssetIds.has(asset.id));
|
||||
if (compatible) return { descriptor: compatible, conflict: false };
|
||||
return { descriptor: null, conflict: nameCandidates.some((asset) => asset.sha256 !== hash) };
|
||||
}
|
||||
|
||||
export function createSource(scene) {
|
||||
const existingIds = new Set(scene.sources.map((source) => source.id));
|
||||
const ordinal = scene.sources.length + 1;
|
||||
return {
|
||||
id: uniqueId(`source-${ordinal}`, existingIds),
|
||||
name: `음원 ${ordinal}`,
|
||||
position: [0, 0, scene.image?.plane?.z ?? scene.sources[0]?.position?.[2] ?? 2],
|
||||
gainDb: -6,
|
||||
startTime: 0,
|
||||
duration: scene.duration,
|
||||
signal: { type: "tone", frequency: Math.min(1000, scene.sampleRate / 4), phaseDeg: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function changeSignalType(source, type, firstAudioAssetId = "") {
|
||||
if (type === "tone") {
|
||||
source.signal = { type: "tone", frequency: 1000, phaseDeg: 0 };
|
||||
} else if (type === "noise") {
|
||||
source.signal = { type: "noise", color: "white", seedOffset: 0 };
|
||||
} else if (type === "asset") {
|
||||
source.signal = { type: "asset", assetId: firstAudioAssetId, channel: 0, loop: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function describeSignal(signal) {
|
||||
if (signal.type === "tone") return `${signal.frequency} Hz tone`;
|
||||
if (signal.type === "noise") return `${signal.color} noise`;
|
||||
if (signal.type === "asset") return `asset · ${signal.assetId || "미선택"}`;
|
||||
return "알 수 없는 신호";
|
||||
}
|
||||
|
||||
export function referencedAssetIds(scene) {
|
||||
const ids = new Set();
|
||||
if (scene.image?.assetId) ids.add(scene.image.assetId);
|
||||
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
|
||||
if (entry.signal?.type === "asset" && entry.signal.assetId) ids.add(entry.signal.assetId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function isAssetReferenced(scene, assetId) {
|
||||
return referencedAssetIds(scene).has(assetId);
|
||||
}
|
||||
|
||||
export function safeDownloadStem(scene) {
|
||||
return uniqueId(scene.id || scene.name || "scene", new Set());
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
.builder-shell {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.workspace-tools,
|
||||
.runtime-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
transition: border-color 140ms ease, background 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus-visible {
|
||||
border-color: var(--mint);
|
||||
outline: none;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
border-color: rgb(101 242 191 / 55%);
|
||||
color: #06130f;
|
||||
background: var(--mint);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: rgb(255 255 255 / 3%);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
border-color: rgb(255 123 115 / 35%);
|
||||
color: var(--danger);
|
||||
background: rgb(255 123 115 / 7%);
|
||||
}
|
||||
|
||||
.button--full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
.builder-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(330px, 400px);
|
||||
min-height: calc(100vh - 65px);
|
||||
}
|
||||
|
||||
.scene-workspace {
|
||||
min-width: 0;
|
||||
padding: clamp(22px, 3vw, 42px);
|
||||
}
|
||||
|
||||
.workspace-heading,
|
||||
.source-strip-heading,
|
||||
.asset-shelf {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.workspace-heading {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.workspace-heading h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: clamp(1.8rem, 4vw, 3.2rem);
|
||||
letter-spacing: -0.055em;
|
||||
}
|
||||
|
||||
.workspace-heading .eyebrow {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.canvas-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 520px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: #0a1512;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
#scene-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: clamp(520px, 61vh, 720px);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-empty {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: min(430px, calc(100% - 48px));
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.canvas-empty strong {
|
||||
color: #cfe2db;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.canvas-empty strong,
|
||||
.canvas-empty span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.canvas-empty.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.canvas-badge {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 999px;
|
||||
color: #d7e8e2;
|
||||
background: rgb(8 17 15 / 78%);
|
||||
backdrop-filter: blur(10px);
|
||||
font: 650 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 28px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-strip-heading {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.source-strip-heading h2,
|
||||
.asset-shelf h2 {
|
||||
margin-bottom: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.source-strip-heading p,
|
||||
.asset-shelf p {
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.source-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 11px;
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 15px;
|
||||
color: inherit;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.source-card:hover,
|
||||
.source-card:focus-visible,
|
||||
.source-card.is-selected {
|
||||
border-color: var(--source-color, var(--mint));
|
||||
outline: none;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.source-card__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-card__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--source-color, var(--mint));
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--source-color, var(--mint)) 16%, transparent);
|
||||
}
|
||||
|
||||
.source-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-card small {
|
||||
color: var(--muted);
|
||||
font: 600 0.72rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.asset-shelf {
|
||||
align-items: flex-start;
|
||||
margin-top: 28px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.asset-shelf > div:first-child {
|
||||
max-width: 470px;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
display: flex;
|
||||
flex: 1 1 320px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
padding: 8px 9px 8px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.asset-chip__label {
|
||||
overflow: hidden;
|
||||
max-width: 210px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.asset-chip__state {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.asset-chip__remove {
|
||||
display: grid;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
background: rgb(255 255 255 / 5%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-chip__remove:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: sticky;
|
||||
top: 65px;
|
||||
align-self: start;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 65px);
|
||||
padding: 18px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: rgb(12 24 21 / 86%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.runtime-line {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.control-section,
|
||||
.validation-card {
|
||||
padding: 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.control-section + .control-section,
|
||||
.validation-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-title > span {
|
||||
color: var(--mint);
|
||||
font: 750 0.68rem ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
background: #0b1714;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: var(--mint);
|
||||
outline: 2px solid rgb(101 242 191 / 11%);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.check-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.check-field input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: var(--mint);
|
||||
}
|
||||
|
||||
.field--wide + .field--wide,
|
||||
.field-grid + .field--wide,
|
||||
.field--wide + .field-grid,
|
||||
#tone-fields,
|
||||
#noise-fields,
|
||||
#asset-fields,
|
||||
.button--full {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.field-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 11px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.validation-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.validation-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
box-shadow: 0 0 0 5px rgb(145 170 162 / 8%);
|
||||
}
|
||||
|
||||
.validation-card strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.validation-card p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.validation-card.is-valid .validation-dot {
|
||||
background: var(--mint);
|
||||
box-shadow: 0 0 0 5px rgb(101 242 191 / 10%);
|
||||
}
|
||||
|
||||
.validation-card.is-invalid .validation-dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 5px rgb(255 123 115 / 10%);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.builder-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: static;
|
||||
max-height: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.builder-header,
|
||||
.workspace-heading,
|
||||
.source-strip-heading,
|
||||
.asset-shelf {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.header-actions .button,
|
||||
.workspace-tools .button {
|
||||
min-width: 0;
|
||||
flex: 1 1 calc(50% - 5px);
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.workspace-tools {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scene-workspace {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.canvas-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
#scene-canvas {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.field-grid--three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ export * from "./das.js";
|
||||
export * from "./fft.js";
|
||||
export * from "./fractional-delay.js";
|
||||
export * from "./math.js";
|
||||
export * from "./mixdown.js";
|
||||
export * from "./noise.js";
|
||||
export * from "./propagation.js";
|
||||
export * from "./signal.js";
|
||||
export * from "./stft.js";
|
||||
export * from "./wav.js";
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderSignal } from "./signal.js";
|
||||
|
||||
export const MAX_MIX_WORKING_BYTES = 384 * 1024 * 1024;
|
||||
export const MAX_MIX_SAMPLE_OPERATIONS = 250_000_000;
|
||||
|
||||
export function estimateSceneMix(scene, assetBuffers = new Map()) {
|
||||
const sampleCount = Math.ceil(scene.duration * scene.sampleRate);
|
||||
const entryCount = scene.sources.length + (scene.ambient?.length ?? 0);
|
||||
let assetBytes = 0;
|
||||
for (const asset of assetBuffers.values()) {
|
||||
for (const channel of asset.channels ?? []) assetBytes += channel.byteLength ?? 0;
|
||||
}
|
||||
return {
|
||||
sampleCount,
|
||||
entryCount,
|
||||
assetBytes,
|
||||
workingBytes: sampleCount * Float32Array.BYTES_PER_ELEMENT * 2 + assetBytes,
|
||||
sampleOperations: sampleCount * entryCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSceneMix(scene, assetBuffers = new Map()) {
|
||||
const estimate = estimateSceneMix(scene, assetBuffers);
|
||||
if (!Number.isSafeInteger(estimate.sampleCount) || estimate.sampleCount <= 0) {
|
||||
throw new Error("장면 sample 수가 올바르지 않습니다.");
|
||||
}
|
||||
if (estimate.workingBytes > MAX_MIX_WORKING_BYTES) {
|
||||
throw new Error(
|
||||
`확인용 mix 작업 메모리 ${(estimate.workingBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`${(MAX_MIX_WORKING_BYTES / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다. duration 또는 sample rate를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
if (estimate.sampleOperations > MAX_MIX_SAMPLE_OPERATIONS) {
|
||||
throw new Error(
|
||||
`확인용 mix 연산량 ${estimate.sampleOperations.toLocaleString()} samples가 ` +
|
||||
`${MAX_MIX_SAMPLE_OPERATIONS.toLocaleString()} 한도를 넘습니다. duration 또는 음원 수를 줄이세요.`,
|
||||
);
|
||||
}
|
||||
const { sampleCount } = estimate;
|
||||
const samples = new Float32Array(sampleCount);
|
||||
for (const source of scene.sources) {
|
||||
const sourceSamples = renderSignal(source, scene, assetBuffers);
|
||||
for (let index = 0; index < sampleCount; index += 1) samples[index] += sourceSamples[index];
|
||||
}
|
||||
for (const ambient of scene.ambient ?? []) {
|
||||
const ambientSamples = renderSignal(ambient, scene, assetBuffers);
|
||||
for (let index = 0; index < sampleCount; index += 1) samples[index] += ambientSamples[index];
|
||||
}
|
||||
|
||||
let preNormalizationPeak = 0;
|
||||
for (const value of samples) preNormalizationPeak = Math.max(preNormalizationPeak, Math.abs(value));
|
||||
const appliedScale =
|
||||
scene.mix?.normalization === "peak" && preNormalizationPeak > 0
|
||||
? scene.mix.targetPeak / preNormalizationPeak
|
||||
: 1;
|
||||
let peak = 0;
|
||||
let clippedSampleCount = 0;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
samples[index] *= appliedScale;
|
||||
peak = Math.max(peak, Math.abs(samples[index]));
|
||||
if (Math.abs(samples[index]) > 1) clippedSampleCount += 1;
|
||||
}
|
||||
return { samples, sampleRate: scene.sampleRate, preNormalizationPeak, appliedScale, peak, clippedSampleCount };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
function writeAscii(view, offset, value) {
|
||||
for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
|
||||
export const MAX_PCM_WAV_BYTES = 384 * 1024 * 1024;
|
||||
|
||||
export function encodeMonoPcm16Wav(samples, sampleRate) {
|
||||
if (!Number.isInteger(sampleRate) || sampleRate < 1 || sampleRate > 0xffffffff) {
|
||||
throw new Error(`WAV sample rate가 올바르지 않습니다: ${sampleRate}`);
|
||||
}
|
||||
if (!samples || !Number.isSafeInteger(samples.length)) {
|
||||
throw new Error("WAV samples는 길이가 있는 배열이어야 합니다.");
|
||||
}
|
||||
const bytesPerSample = 2;
|
||||
const dataByteLength = samples.length * bytesPerSample;
|
||||
if (!Number.isSafeInteger(dataByteLength) || dataByteLength > 0xffffffff - 36) {
|
||||
throw new Error("PCM16 WAV가 RIFF 4 GiB 한도를 넘습니다.");
|
||||
}
|
||||
if (44 + dataByteLength > MAX_PCM_WAV_BYTES) {
|
||||
throw new Error(`PCM16 WAV가 ${(MAX_PCM_WAV_BYTES / 1024 ** 2).toFixed(0)} MiB 메모리 한도를 넘습니다.`);
|
||||
}
|
||||
const buffer = new ArrayBuffer(44 + dataByteLength);
|
||||
const view = new DataView(buffer);
|
||||
writeAscii(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataByteLength, true);
|
||||
writeAscii(view, 8, "WAVE");
|
||||
writeAscii(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * bytesPerSample, true);
|
||||
view.setUint16(32, bytesPerSample, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeAscii(view, 36, "data");
|
||||
view.setUint32(40, dataByteLength, true);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
if (!Number.isFinite(samples[index])) throw new Error(`WAV sample ${index}가 유한한 숫자가 아닙니다.`);
|
||||
const value = Math.max(-1, Math.min(1, samples[index]));
|
||||
view.setInt16(44 + index * 2, value < 0 ? value * 0x8000 : value * 0x7fff, true);
|
||||
}
|
||||
return new Blob([buffer], { type: "audio/wav" });
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
export const MAX_DECODED_AUDIO_BYTES = 192 * 1024 * 1024;
|
||||
const MAX_WAV_HEADER_BYTES = 1024 * 1024;
|
||||
const CONSERVATIVE_COMPRESSED_CHANNELS = 32;
|
||||
|
||||
function ascii(view, offset, length) {
|
||||
let value = "";
|
||||
for (let index = 0; index < length; index += 1) value += String.fromCharCode(view.getUint8(offset + index));
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readWavMetadata(file, signal) {
|
||||
throwIfAborted(signal);
|
||||
const header = await file.slice(0, Math.min(file.size, MAX_WAV_HEADER_BYTES)).arrayBuffer();
|
||||
throwIfAborted(signal);
|
||||
const view = new DataView(header);
|
||||
if (view.byteLength < 12 || ascii(view, 0, 4) !== "RIFF" || ascii(view, 8, 4) !== "WAVE") {
|
||||
throw new Error(`${file.name} WAV의 RIFF/WAVE header가 올바르지 않습니다.`);
|
||||
}
|
||||
let offset = 12;
|
||||
let format = null;
|
||||
let dataByteLength = null;
|
||||
while (offset + 8 <= view.byteLength) {
|
||||
const chunkId = ascii(view, offset, 4);
|
||||
const chunkSize = view.getUint32(offset + 4, true);
|
||||
const payloadOffset = offset + 8;
|
||||
if (chunkId === "fmt " && chunkSize >= 16 && payloadOffset + 16 <= view.byteLength) {
|
||||
format = {
|
||||
channels: view.getUint16(payloadOffset + 2, true),
|
||||
sampleRate: view.getUint32(payloadOffset + 4, true),
|
||||
byteRate: view.getUint32(payloadOffset + 8, true),
|
||||
};
|
||||
} else if (chunkId === "data") {
|
||||
dataByteLength = chunkSize;
|
||||
break;
|
||||
}
|
||||
const nextOffset = payloadOffset + chunkSize + (chunkSize % 2);
|
||||
if (!Number.isSafeInteger(nextOffset) || nextOffset <= offset) break;
|
||||
offset = nextOffset;
|
||||
}
|
||||
if (!format || dataByteLength === null || format.channels < 1 || format.channels > 32 || format.byteRate < 1) {
|
||||
throw new Error(`${file.name} WAV의 fmt/data chunk를 안전하게 읽지 못했습니다.`);
|
||||
}
|
||||
return { channels: format.channels, duration: dataByteLength / format.byteRate };
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) throw new DOMException("오디오 decode가 취소됐습니다.", "AbortError");
|
||||
}
|
||||
|
||||
async function probeAudioDuration(file, signal) {
|
||||
throwIfAborted(signal);
|
||||
const url = URL.createObjectURL(file);
|
||||
const audio = document.createElement("audio");
|
||||
audio.preload = "metadata";
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (value, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (error) reject(error);
|
||||
else resolve(value);
|
||||
};
|
||||
const onAbort = () => finish(null, new DOMException("오디오 metadata 확인이 취소됐습니다.", "AbortError"));
|
||||
const timeout = setTimeout(() => finish(null), 10000);
|
||||
audio.addEventListener("loadedmetadata", () => finish(Number.isFinite(audio.duration) ? audio.duration : null), { once: true });
|
||||
audio.addEventListener("error", () => finish(null), { once: true });
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
audio.src = url;
|
||||
});
|
||||
} finally {
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function estimateDecodedBytes(file, targetSampleRate, signal) {
|
||||
const isWav = /(?:audio\/(?:x-)?wav)/i.test(file.type) || /\.wav$/i.test(file.name);
|
||||
if (isWav) {
|
||||
const metadata = await readWavMetadata(file, signal);
|
||||
return Math.ceil(metadata.duration * targetSampleRate) * metadata.channels * Float32Array.BYTES_PER_ELEMENT;
|
||||
}
|
||||
const duration = await probeAudioDuration(file, signal);
|
||||
if (duration === null) {
|
||||
throw new Error(`${file.name}의 decode 크기를 미리 확인할 수 없습니다. PCM WAV로 변환해 사용하세요.`);
|
||||
}
|
||||
return Math.ceil(duration * targetSampleRate) * CONSERVATIVE_COMPRESSED_CHANNELS * Float32Array.BYTES_PER_ELEMENT;
|
||||
}
|
||||
|
||||
export async function decodeAudioFile(file, targetSampleRate, maximumDecodedBytes = MAX_DECODED_AUDIO_BYTES, signal) {
|
||||
if (!Number.isInteger(targetSampleRate) || targetSampleRate < 8000 || targetSampleRate > 192000) {
|
||||
throw new Error(`지원하지 않는 decode sample rate입니다: ${targetSampleRate}`);
|
||||
}
|
||||
try {
|
||||
const estimatedBytes = await estimateDecodedBytes(file, targetSampleRate, signal);
|
||||
throwIfAborted(signal);
|
||||
if (!Number.isSafeInteger(estimatedBytes) || estimatedBytes < 0 || estimatedBytes > maximumDecodedBytes) {
|
||||
throw new Error(
|
||||
`${file.name} 예상 decode 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`${(maximumDecodedBytes / 1024 ** 2).toFixed(1)} MiB 잔여 한도를 넘습니다.`,
|
||||
);
|
||||
}
|
||||
const context = new OfflineAudioContext(1, 1, targetSampleRate);
|
||||
const fileBytes = await file.arrayBuffer();
|
||||
throwIfAborted(signal);
|
||||
const decoded = await context.decodeAudioData(fileBytes);
|
||||
throwIfAborted(signal);
|
||||
const decodedBytes = decoded.length * decoded.numberOfChannels * Float32Array.BYTES_PER_ELEMENT;
|
||||
if (decodedBytes > maximumDecodedBytes) {
|
||||
throw new Error(
|
||||
`${file.name} decode 크기 ${(decodedBytes / 1024 ** 2).toFixed(1)} MiB가 ` +
|
||||
`${(maximumDecodedBytes / 1024 ** 2).toFixed(0)} MiB 한도를 넘습니다.`,
|
||||
);
|
||||
}
|
||||
const channels = [];
|
||||
for (let channel = 0; channel < decoded.numberOfChannels; channel += 1) {
|
||||
channels.push(decoded.getChannelData(channel));
|
||||
}
|
||||
return { sampleRate: decoded.sampleRate, channels };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") throw error;
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${file.name} 오디오를 ${targetSampleRate} Hz로 decode하지 못했습니다: ${reason}`);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,20 @@ export function downloadJson(value, fileName) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function downloadBlob(blob, fileName) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export async function sha256File(file) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function deepClone(value) {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, received ${actual}`);
|
||||
}
|
||||
|
||||
async function waitFor(predicate, label, timeoutMs = 15000) {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (performance.now() < deadline) {
|
||||
const value = predicate();
|
||||
if (value) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
throw new Error(`${label} 대기 시간이 초과됐습니다.`);
|
||||
}
|
||||
|
||||
function dispatchValue(windowObject, element, value, eventName = "input") {
|
||||
element.value = String(value);
|
||||
element.dispatchEvent(new windowObject.Event(eventName, { bubbles: true }));
|
||||
}
|
||||
|
||||
function dispatchChecked(windowObject, element, checked) {
|
||||
element.checked = checked;
|
||||
element.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function dispatchFiles(windowObject, input, files) {
|
||||
const transfer = new windowObject.DataTransfer();
|
||||
files.forEach((file) => transfer.items.add(file));
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new windowObject.Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function digestHex(blob) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
||||
return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 preview WAV를 재현한다", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = "/apps/dataset-builder/?integration-test=1";
|
||||
frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1400px;height:1000px;border:0";
|
||||
document.body.append(frame);
|
||||
await new Promise((resolve, reject) => {
|
||||
frame.addEventListener("load", resolve, { once: true });
|
||||
frame.addEventListener("error", () => reject(new Error("Builder iframe load failed")), { once: true });
|
||||
});
|
||||
|
||||
const windowObject = frame.contentWindow;
|
||||
const documentObject = frame.contentDocument;
|
||||
const objectUrls = [];
|
||||
const downloads = [];
|
||||
const originalRevoke = windowObject.URL.revokeObjectURL.bind(windowObject.URL);
|
||||
const originalAnchorClick = windowObject.HTMLAnchorElement.prototype.click;
|
||||
windowObject.URL.revokeObjectURL = (url) => objectUrls.push(url);
|
||||
windowObject.HTMLAnchorElement.prototype.click = function captureDownload() {
|
||||
downloads.push({ fileName: this.download, url: this.href });
|
||||
};
|
||||
|
||||
try {
|
||||
await waitFor(() => documentObject.documentElement.dataset.appReady === "true", "Builder 초기화");
|
||||
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "기본 scene 검증");
|
||||
assert(documentObject.querySelector("#scene-id").value !== "single-tone", "new default scene gets a unique id");
|
||||
|
||||
const previewSamples = new Float32Array(2000);
|
||||
for (let index = 0; index < previewSamples.length; index += 1) {
|
||||
previewSamples[index] = Math.sin((2 * Math.PI * 440 * index) / 8000) * 0.12;
|
||||
}
|
||||
const wavBlob = encodeMonoPcm16Wav(previewSamples, 8000);
|
||||
const audioFile = new windowObject.File([wavBlob], "machine.wav", { type: "audio/wav" });
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="#173128"/><circle cx="420" cy="170" r="70" fill="#65f2bf"/></svg>';
|
||||
const imageFile = new windowObject.File([svg], "machine.svg", { type: "image/svg+xml" });
|
||||
const staleSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="#ff0000"/></svg>';
|
||||
const staleImageFile = new windowObject.File([staleSvg], "stale.svg", { type: "image/svg+xml" });
|
||||
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#audio-input"), [audioFile]);
|
||||
await waitFor(
|
||||
() => [...documentObject.querySelectorAll(".asset-chip__state")].some((item) => item.textContent === "연결됨"),
|
||||
"오디오 hash/decode",
|
||||
);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [staleImageFile]);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
|
||||
await waitFor(() => documentObject.querySelectorAll(".asset-chip__state").length === 2, "이미지 decode");
|
||||
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "asset 준비");
|
||||
|
||||
documentObject.querySelector("#add-source-button").click();
|
||||
assertEqual(documentObject.querySelectorAll(".source-card").length, 2, "source card count");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#source-name"), "asset source");
|
||||
dispatchValue(windowObject, documentObject.querySelector("#source-type"), "asset", "change");
|
||||
dispatchChecked(windowObject, documentObject.querySelector("#source-loop"), true);
|
||||
|
||||
const canvas = documentObject.querySelector("#scene-canvas");
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
canvas.dispatchEvent(new windowObject.PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
clientX: canvasRect.left + canvasRect.width * 0.75,
|
||||
clientY: canvasRect.top + canvasRect.height * 0.25,
|
||||
}));
|
||||
assertNear(Number(documentObject.querySelector("#source-x").value), 0.3, 0.002, "canvas x");
|
||||
assertNear(Number(documentObject.querySelector("#source-y").value), 0.225, 0.002, "canvas y");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#ambient-type"), "asset", "change");
|
||||
dispatchChecked(windowObject, documentObject.querySelector("#ambient-loop"), true);
|
||||
await waitFor(() => documentObject.documentElement.dataset.sceneValid === "true", "편집 scene 검증");
|
||||
|
||||
documentObject.querySelector("#wav-button").click();
|
||||
const firstWavDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".preview-mix.wav")),
|
||||
"첫 preview WAV",
|
||||
);
|
||||
const firstWav = await fetch(firstWavDownload.url).then((response) => response.blob());
|
||||
const firstWavHash = await digestHex(firstWav);
|
||||
|
||||
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "첫 JSON 저장 버튼 복구");
|
||||
documentObject.querySelector("#export-button").click();
|
||||
const firstJsonDownload = await waitFor(
|
||||
() => downloads.find((download) => download.fileName.endsWith(".scene.json")),
|
||||
"첫 Scene JSON",
|
||||
);
|
||||
const firstJsonText = await fetch(firstJsonDownload.url).then((response) => response.text());
|
||||
const firstScene = JSON.parse(firstJsonText);
|
||||
assertEqual(firstScene.sources.length, 2, "exported source count");
|
||||
assertEqual(firstScene.sources[1].signal.loop, true, "source loop exported");
|
||||
assertEqual(firstScene.ambient[0].signal.loop, true, "ambient loop exported");
|
||||
assertEqual(firstScene.image.widthPixels, 640, "image width exported");
|
||||
assert(firstScene.assets.every((asset) => /^[a-f0-9]{64}$/.test(asset.sha256)), "asset hashes exported");
|
||||
|
||||
const sceneFile = new windowObject.File([firstJsonText], "roundtrip.scene.json", { type: "application/json" });
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [sceneFile]);
|
||||
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("Scene 불러옴"), "Scene 재로드");
|
||||
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "false", "재로드 후 원본 파일 요구");
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#audio-input"), [audioFile]);
|
||||
await waitFor(
|
||||
() => [...documentObject.querySelectorAll(".asset-chip")].some((chip) =>
|
||||
chip.querySelector(".asset-chip__label").textContent.includes("machine.wav") &&
|
||||
chip.querySelector(".asset-chip__state").textContent === "연결됨"),
|
||||
"오디오 SHA 재연결",
|
||||
);
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
|
||||
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "이미지 SHA 재연결");
|
||||
|
||||
const downloadCountBeforeSecondWav = downloads.length;
|
||||
documentObject.querySelector("#wav-button").click();
|
||||
const secondWavDownload = await waitFor(
|
||||
() => downloads.slice(downloadCountBeforeSecondWav).find((download) => download.fileName.endsWith(".preview-mix.wav")),
|
||||
"두 번째 preview WAV",
|
||||
);
|
||||
const secondWav = await fetch(secondWavDownload.url).then((response) => response.blob());
|
||||
assertEqual(await digestHex(secondWav), firstWavHash, "round-trip preview WAV hash");
|
||||
|
||||
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "두 번째 JSON 저장 버튼 복구");
|
||||
const downloadCountBeforeSecondJson = downloads.length;
|
||||
documentObject.querySelector("#export-button").click();
|
||||
const secondJsonDownload = await waitFor(
|
||||
() => downloads.slice(downloadCountBeforeSecondJson).find((download) => download.fileName.endsWith(".scene.json")),
|
||||
"두 번째 Scene JSON",
|
||||
);
|
||||
const secondScene = await fetch(secondJsonDownload.url).then((response) => response.json());
|
||||
assertEqual(JSON.stringify(secondScene), JSON.stringify(firstScene), "canonical scene round trip");
|
||||
|
||||
documentObject.querySelector("#remove-image-button").click();
|
||||
await waitFor(() => documentObject.querySelector("#remove-image-button").classList.contains("is-hidden"), "image remove");
|
||||
await waitFor(() => !documentObject.querySelector("#export-button").disabled, "grid-only JSON button");
|
||||
const downloadCountBeforeGridJson = downloads.length;
|
||||
documentObject.querySelector("#export-button").click();
|
||||
const gridJsonDownload = await waitFor(
|
||||
() => downloads.slice(downloadCountBeforeGridJson).find((download) => download.fileName.endsWith(".scene.json")),
|
||||
"grid-only Scene JSON",
|
||||
);
|
||||
const gridScene = await fetch(gridJsonDownload.url).then((response) => response.json());
|
||||
assertEqual(gridScene.image, undefined, "image reference removed");
|
||||
|
||||
const stableName = documentObject.querySelector("#scene-name").value;
|
||||
const invalidFile = new windowObject.File(["{"], "invalid.scene.json", { type: "application/json" });
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [invalidFile]);
|
||||
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("불러오기 실패"), "invalid import error");
|
||||
assertEqual(documentObject.querySelector("#scene-name").value, stableName, "invalid import preserves scene");
|
||||
|
||||
dispatchValue(windowObject, documentObject.querySelector("#sample-rate"), "96000", "change");
|
||||
await waitFor(() => !documentObject.querySelector("#wav-button").disabled, "cancel fixture validation");
|
||||
const downloadCountBeforeCancel = downloads.length;
|
||||
documentObject.querySelector("#wav-button").click();
|
||||
const cancelSceneFile = new windowObject.File([firstJsonText], "cancel.scene.json", { type: "application/json" });
|
||||
dispatchFiles(windowObject, documentObject.querySelector("#scene-input"), [cancelSceneFile]);
|
||||
await waitFor(() => documentObject.querySelector("#save-state").textContent.startsWith("Scene 불러옴"), "import during decode");
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
assertEqual(downloads.length, downloadCountBeforeCancel, "cancelled decode does not create stale download");
|
||||
} finally {
|
||||
windowObject.HTMLAnchorElement.prototype.click = originalAnchorClick;
|
||||
windowObject.URL.revokeObjectURL = originalRevoke;
|
||||
for (const url of objectUrls) originalRevoke(url);
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,9 @@ globalThis.assertEqual = (actual, expected, message = "values differ") => {
|
||||
try {
|
||||
await import("/tests/unit/contracts.test.js");
|
||||
await import("/tests/unit/acoustics.test.js");
|
||||
await import("/tests/unit/builder.test.js");
|
||||
await import("/tests/integration/cpu-baseline.test.js");
|
||||
await import("/tests/integration/builder-ui.test.js");
|
||||
} catch (error) {
|
||||
registeredTests.push({
|
||||
name: "테스트 모듈을 불러온다",
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
prepareDelaySpectrum,
|
||||
} from "/packages/acoustics-core/fractional-delay.js";
|
||||
import { dbToLinear, distance3 } from "/packages/acoustics-core/math.js";
|
||||
import { renderSceneMix } from "/packages/acoustics-core/mixdown.js";
|
||||
import { whiteNoiseAt } from "/packages/acoustics-core/noise.js";
|
||||
import { sphericalAttenuation, synthesizeMicrophoneSignals } from "/packages/acoustics-core/propagation.js";
|
||||
import { getFrameCount } from "/packages/acoustics-core/stft.js";
|
||||
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected} ± ${tolerance}, received ${actual}`);
|
||||
@@ -302,3 +304,68 @@ test("CPU DAS가 event-loop 취소와 working-set 한도를 지킨다", async ()
|
||||
assert(stftStarted, "STFT started before abort");
|
||||
assert(abortError?.name === "AbortError", "async abort");
|
||||
});
|
||||
|
||||
test("scene mix가 peak 정규화와 clipping 통계를 정확히 기록한다", () => {
|
||||
const assetBuffers = new Map([
|
||||
["levels", { sampleRate: 4, channels: [Float32Array.of(2, -1.5, 1, -0.5)] }],
|
||||
]);
|
||||
const scene = {
|
||||
sampleRate: 4,
|
||||
duration: 1,
|
||||
seed: 1,
|
||||
mix: { normalization: "peak", targetPeak: 0.5 },
|
||||
sources: [{
|
||||
id: "levels",
|
||||
gainDb: 0,
|
||||
startTime: 0,
|
||||
duration: 1,
|
||||
signal: { type: "asset", assetId: "levels" },
|
||||
}],
|
||||
ambient: [],
|
||||
};
|
||||
|
||||
const normalized = renderSceneMix(scene, assetBuffers);
|
||||
assertNear(normalized.preNormalizationPeak, 2, 1e-12, "pre-normalization peak");
|
||||
assertNear(normalized.appliedScale, 0.25, 1e-12, "normalization scale");
|
||||
assertNear(normalized.peak, 0.5, 1e-12, "normalized peak");
|
||||
assertEqual(normalized.clippedSampleCount, 0, "normalized clipping count");
|
||||
[0.5, -0.375, 0.25, -0.125].forEach((expected, index) => {
|
||||
assertNear(normalized.samples[index], expected, 1e-12, `normalized sample ${index}`);
|
||||
});
|
||||
|
||||
const unnormalized = renderSceneMix({ ...scene, mix: { normalization: "none", targetPeak: 0.5 } }, assetBuffers);
|
||||
assertNear(unnormalized.preNormalizationPeak, 2, 1e-12, "unnormalized input peak");
|
||||
assertNear(unnormalized.appliedScale, 1, 1e-12, "unnormalized scale");
|
||||
assertNear(unnormalized.peak, 2, 1e-12, "unnormalized output peak");
|
||||
assertEqual(unnormalized.clippedSampleCount, 2, "unnormalized clipping count");
|
||||
});
|
||||
|
||||
test("PCM16 WAV가 표준 mono header를 쓰고 범위 밖 sample을 clamp한다", async () => {
|
||||
const blob = encodeMonoPcm16Wav(Float32Array.of(-2, -1, -0.5, 0, 0.5, 1, 2), 8000);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const view = new DataView(buffer);
|
||||
const ascii = (offset, length) => String.fromCharCode(...bytes.subarray(offset, offset + length));
|
||||
|
||||
assertEqual(blob.type, "audio/wav", "WAV MIME type");
|
||||
assertEqual(buffer.byteLength, 58, "WAV byte length");
|
||||
assertEqual(ascii(0, 4), "RIFF", "RIFF id");
|
||||
assertEqual(view.getUint32(4, true), 50, "RIFF chunk size");
|
||||
assertEqual(ascii(8, 4), "WAVE", "WAVE id");
|
||||
assertEqual(ascii(12, 4), "fmt ", "fmt id");
|
||||
assertEqual(view.getUint32(16, true), 16, "PCM fmt chunk size");
|
||||
assertEqual(view.getUint16(20, true), 1, "PCM format");
|
||||
assertEqual(view.getUint16(22, true), 1, "mono channel count");
|
||||
assertEqual(view.getUint32(24, true), 8000, "sample rate");
|
||||
assertEqual(view.getUint32(28, true), 16000, "byte rate");
|
||||
assertEqual(view.getUint16(32, true), 2, "block align");
|
||||
assertEqual(view.getUint16(34, true), 16, "bits per sample");
|
||||
assertEqual(ascii(36, 4), "data", "data id");
|
||||
assertEqual(view.getUint32(40, true), 14, "PCM data byte length");
|
||||
|
||||
const actualSamples = Array.from({ length: 7 }, (_, index) => view.getInt16(44 + index * 2, true));
|
||||
const expectedSamples = [-32768, -32768, -16384, 0, 16383, 32767, 32767];
|
||||
expectedSamples.forEach((expected, index) => {
|
||||
assertEqual(actualSamples[index], expected, `PCM sample ${index}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { coordinateToPixel, pixelToCoordinate } from "/apps/dataset-builder/canvas-view.js";
|
||||
import {
|
||||
assetDescriptorForFile,
|
||||
createSceneId,
|
||||
createSource,
|
||||
matchAssetDescriptor,
|
||||
prepareSceneForEditing,
|
||||
referencedAssetIds,
|
||||
} from "/apps/dataset-builder/model.js";
|
||||
import { decodeAudioFile } from "/packages/audio-io/browser-audio.js";
|
||||
import {
|
||||
MAX_MIX_SAMPLE_OPERATIONS,
|
||||
MAX_MIX_WORKING_BYTES,
|
||||
renderSceneMix,
|
||||
} from "/packages/acoustics-core/mixdown.js";
|
||||
import { encodeMonoPcm16Wav } from "/packages/acoustics-core/wav.js";
|
||||
|
||||
function assertNear(actual, expected, tolerance, label) {
|
||||
assert(Math.abs(actual - expected) <= tolerance, `${label}: expected ${expected}, received ${actual}`);
|
||||
}
|
||||
|
||||
test("Builder model이 schema-safe하고 안정적인 source/asset id를 만든다", () => {
|
||||
const original = {
|
||||
id: "scene",
|
||||
sampleRate: 8000,
|
||||
duration: 1,
|
||||
sources: [{ id: "source-1", position: [0, 0, 2] }],
|
||||
};
|
||||
const scene = prepareSceneForEditing(original);
|
||||
const added = createSource(scene);
|
||||
assertEqual(added.id, "source-2", "unique source id");
|
||||
assertEqual(original.assets, undefined, "input remains immutable");
|
||||
assertEqual(scene.mix.targetPeak, 0.95, "default target peak");
|
||||
|
||||
const descriptor = assetDescriptorForFile(
|
||||
{ name: "기계음 샘플.WAV", type: "audio/wav" },
|
||||
"audio",
|
||||
new Set(["audio"]),
|
||||
);
|
||||
assert(/^[a-z0-9][a-z0-9._-]*$/.test(descriptor.id), "schema-safe asset id");
|
||||
assertEqual(descriptor.id, "audio-2", "collision suffix");
|
||||
assertEqual(createSceneId(123456, 789), "scene-2n9c-lx", "deterministic scene id");
|
||||
assert(/^[a-z0-9][a-z0-9._-]*$/.test(createSceneId()), "generated scene id is schema-safe");
|
||||
});
|
||||
|
||||
test("asset 재연결은 fileName보다 SHA-256을 우선하고 충돌을 분리한다", () => {
|
||||
const assets = [
|
||||
{ id: "old", kind: "audio", fileName: "audio/machine.wav", sha256: "a".repeat(64) },
|
||||
{ id: "unhashed", kind: "audio", fileName: "ambient.wav" },
|
||||
];
|
||||
const hashMatch = matchAssetDescriptor(assets, new Set(), "renamed.wav", "audio", "a".repeat(64));
|
||||
assertEqual(hashMatch.descriptor.id, "old", "hash match");
|
||||
const nameMatch = matchAssetDescriptor(assets, new Set(), "ambient.wav", "audio", "b".repeat(64));
|
||||
assertEqual(nameMatch.descriptor.id, "unhashed", "unhashed name match");
|
||||
const conflict = matchAssetDescriptor(assets, new Set(), "machine.wav", "audio", "c".repeat(64));
|
||||
assertEqual(conflict.descriptor, null, "different hash does not reconnect");
|
||||
assertEqual(conflict.conflict, true, "same-name conflict reported");
|
||||
|
||||
const duplicateHashAssets = [
|
||||
{ id: "copy-1", kind: "audio", fileName: "copy-1.wav", sha256: "d".repeat(64) },
|
||||
{ id: "copy-2", kind: "audio", fileName: "copy-2.wav", sha256: "d".repeat(64) },
|
||||
];
|
||||
const secondCopy = matchAssetDescriptor(duplicateHashAssets, new Set(["copy-1"]), "same.wav", "audio", "d".repeat(64));
|
||||
assertEqual(secondCopy.descriptor.id, "copy-2", "unbound duplicate hash descriptor");
|
||||
});
|
||||
|
||||
test("Builder가 image/source/ambient에서 참조하는 asset을 모두 찾는다", () => {
|
||||
const ids = referencedAssetIds({
|
||||
image: { assetId: "image" },
|
||||
sources: [
|
||||
{ signal: { type: "asset", assetId: "machine" } },
|
||||
{ signal: { type: "tone", frequency: 1000 } },
|
||||
],
|
||||
ambient: [{ signal: { type: "asset", assetId: "ambient" } }],
|
||||
});
|
||||
assertEqual([...ids].join(","), "image,machine,ambient", "referenced assets");
|
||||
});
|
||||
|
||||
test("canvas 좌표 변환이 y-up 평면의 중앙과 모서리를 왕복한다", () => {
|
||||
const plane = { xMin: -2, xMax: 2, yMin: -1, yMax: 3 };
|
||||
const topLeft = pixelToCoordinate({ x: 0, y: 0 }, plane, 400, 200);
|
||||
assertEqual(topLeft.x, -2, "left is xMin");
|
||||
assertEqual(topLeft.y, 3, "top is yMax");
|
||||
const bottomRight = pixelToCoordinate({ x: 400, y: 200 }, plane, 400, 200);
|
||||
assertEqual(bottomRight.x, 2, "right is xMax");
|
||||
assertEqual(bottomRight.y, -1, "bottom is yMin");
|
||||
const pixel = coordinateToPixel([0.75, 1.25, 2], plane, 400, 200);
|
||||
const roundTrip = pixelToCoordinate(pixel, plane, 400, 200);
|
||||
assertNear(roundTrip.x, 0.75, 1e-12, "x round trip");
|
||||
assertNear(roundTrip.y, 1.25, 1e-12, "y round trip");
|
||||
});
|
||||
|
||||
test("확인용 mix가 과도한 메모리와 sample 연산을 allocation 전에 거부한다", () => {
|
||||
const signal = { type: "tone", frequency: 1000 };
|
||||
const largeMemory = {
|
||||
sampleRate: 192000,
|
||||
duration: 300,
|
||||
seed: 1,
|
||||
mix: { normalization: "none", targetPeak: 0.95 },
|
||||
sources: [{ id: "source", gainDb: 0, startTime: 0, duration: 300, signal }],
|
||||
ambient: [],
|
||||
};
|
||||
let memoryError;
|
||||
try { renderSceneMix(largeMemory); } catch (error) { memoryError = error; }
|
||||
assert(memoryError?.message.includes("작업 메모리"), "memory guard message");
|
||||
assert(MAX_MIX_WORKING_BYTES < 192000 * 300 * 8, "fixture exceeds memory guard");
|
||||
|
||||
const sources = Array.from({ length: 18 }, (_, index) => ({
|
||||
id: `source-${index}`, gainDb: 0, startTime: 0, duration: 300, signal,
|
||||
}));
|
||||
const largeOperations = { ...largeMemory, sampleRate: 48000, sources };
|
||||
let operationError;
|
||||
try { renderSceneMix(largeOperations); } catch (error) { operationError = error; }
|
||||
assert(operationError?.message.includes("연산량"), "operation guard message");
|
||||
assert(MAX_MIX_SAMPLE_OPERATIONS < 48000 * 300 * sources.length, "fixture exceeds operation guard");
|
||||
});
|
||||
|
||||
test("WAV encoder가 잘못된 sample rate와 비유한 sample을 거부한다", () => {
|
||||
let rateError;
|
||||
try { encodeMonoPcm16Wav(Float32Array.of(0), 0); } catch (error) { rateError = error; }
|
||||
assert(rateError?.message.includes("sample rate"), "sample-rate guard");
|
||||
let sampleError;
|
||||
try { encodeMonoPcm16Wav(Float32Array.of(0, Number.NaN), 48000); } catch (error) { sampleError = error; }
|
||||
assert(sampleError?.message.includes("유한한"), "finite-sample guard");
|
||||
});
|
||||
|
||||
test("브라우저 audio decoder가 caller의 누적 메모리 budget을 지킨다", async () => {
|
||||
const wav = encodeMonoPcm16Wav(Float32Array.of(0, 0.25, -0.25, 0), 8000);
|
||||
const file = new File([wav], "tiny.wav", { type: "audio/wav" });
|
||||
let budgetError;
|
||||
try { await decodeAudioFile(file, 8000, 8); } catch (error) { budgetError = error; }
|
||||
assert(budgetError?.message.includes("decode 크기"), "decoded-byte budget error");
|
||||
|
||||
const header = new ArrayBuffer(44);
|
||||
const view = new DataView(header);
|
||||
const write = (offset, text) => [...text].forEach((character, index) => view.setUint8(offset + index, character.charCodeAt(0)));
|
||||
const channels = 4;
|
||||
const sampleRate = 48000;
|
||||
const dataByteLength = sampleRate * 300 * channels * 2;
|
||||
write(0, "RIFF"); view.setUint32(4, 36 + dataByteLength, true); write(8, "WAVE"); write(12, "fmt ");
|
||||
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true);
|
||||
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * channels * 2, true);
|
||||
view.setUint16(32, channels * 2, true); view.setUint16(34, 16, true); write(36, "data");
|
||||
view.setUint32(40, dataByteLength, true);
|
||||
const multichannelFile = new File([header], "four-channel.wav", { type: "audio/wav" });
|
||||
let channelError;
|
||||
try { await decodeAudioFile(multichannelFile, sampleRate); } catch (error) { channelError = error; }
|
||||
assert(channelError?.message.includes("예상 decode 크기 219.7 MiB"), "WAV channel-count preflight");
|
||||
});
|
||||
|
||||
test("Builder mix Worker가 기본 tone 장면을 별도 thread에서 생성한다", async () => {
|
||||
const response = await fetch("/assets/sample-scenes/single-tone.scene.json");
|
||||
const scene = await response.json();
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const worker = new Worker("/apps/dataset-builder/mix-worker.js", { type: "module" });
|
||||
worker.addEventListener("message", ({ data }) => {
|
||||
worker.terminate();
|
||||
if (data.ok) resolve(data.result);
|
||||
else reject(new Error(data.message));
|
||||
});
|
||||
worker.addEventListener("error", (event) => {
|
||||
worker.terminate();
|
||||
reject(new Error(event.message));
|
||||
});
|
||||
worker.postMessage({ scene, assetBuffers: [] });
|
||||
});
|
||||
assertEqual(result.samples.length, 12000, "worker sample count");
|
||||
assert(result.peak > 0.5 && result.peak < 0.502, "worker tone peak");
|
||||
assertEqual(result.clippedSampleCount, 0, "worker clipping count");
|
||||
});
|
||||
Reference in New Issue
Block a user