diff --git a/apps/dataset-builder/audio-runtime.js b/apps/dataset-builder/audio-runtime.js new file mode 100644 index 0000000..018c836 --- /dev/null +++ b/apps/dataset-builder/audio-runtime.js @@ -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, + }; +} diff --git a/apps/dataset-builder/canvas-view.js b/apps/dataset-builder/canvas-view.js new file mode 100644 index 0000000..db60449 --- /dev/null +++ b/apps/dataset-builder/canvas-view.js @@ -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 }; +} diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html index 3bc582d..9dc7a23 100644 --- a/apps/dataset-builder/index.html +++ b/apps/dataset-builder/index.html @@ -6,27 +6,159 @@ Dataset Builder · micarrsim + -
-
+
+
micarrsim Dataset Builder - 런타임 확인 중… +
+ + + +
-
-
-

Scene authoring

-

음향 장면 만들기

-

- 이 화면에서 기기 이미지에 음원을 배치하고 ambient audio를 혼합해 scene manifest와 - ground truth를 만들게 됩니다. 공통 데이터 계약을 확정한 뒤 편집기를 연결합니다. -

+ +
+
+
+
+

Ground-truth canvas

+

음향 장면

+
+
+ + + + +
+
+ +
+ +
+ 이미지를 추가하거나 grid를 그대로 사용하세요. + 선택한 음원을 옮기려면 canvas를 클릭하세요. +
+
x −0.60…0.60 m · y −0.45…0.45 m
+
+ +
+
+

음원 ground truth

+

카드를 선택한 뒤 canvas를 클릭해 위치를 지정합니다.

+
+ +
+
+ +
+
+

로컬 assets

+

원본 파일은 업로드되지 않습니다. Camera에서 JSON과 함께 같은 파일을 선택하세요.

+
+
+
+ +
+ diff --git a/apps/dataset-builder/main.js b/apps/dataset-builder/main.js new file mode 100644 index 0000000..795a3a8 --- /dev/null +++ b/apps/dataset-builder/main.js @@ -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("초기화 실패"); +}); diff --git a/apps/dataset-builder/mix-worker.js b/apps/dataset-builder/mix-worker.js new file mode 100644 index 0000000..b62cc43 --- /dev/null +++ b/apps/dataset-builder/mix-worker.js @@ -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), + }); + } +}); diff --git a/apps/dataset-builder/model.js b/apps/dataset-builder/model.js new file mode 100644 index 0000000..1ba8e0a --- /dev/null +++ b/apps/dataset-builder/model.js @@ -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()); +} diff --git a/apps/dataset-builder/styles.css b/apps/dataset-builder/styles.css new file mode 100644 index 0000000..183dd99 --- /dev/null +++ b/apps/dataset-builder/styles.css @@ -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; + } +} diff --git a/packages/acoustics-core/index.js b/packages/acoustics-core/index.js index db3cee1..6be2685 100644 --- a/packages/acoustics-core/index.js +++ b/packages/acoustics-core/index.js @@ -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"; diff --git a/packages/acoustics-core/mixdown.js b/packages/acoustics-core/mixdown.js new file mode 100644 index 0000000..0303593 --- /dev/null +++ b/packages/acoustics-core/mixdown.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 }; +} diff --git a/packages/acoustics-core/wav.js b/packages/acoustics-core/wav.js new file mode 100644 index 0000000..76888ec --- /dev/null +++ b/packages/acoustics-core/wav.js @@ -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" }); +} diff --git a/packages/audio-io/browser-audio.js b/packages/audio-io/browser-audio.js new file mode 100644 index 0000000..01769a1 --- /dev/null +++ b/packages/audio-io/browser-audio.js @@ -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}`); + } +} diff --git a/packages/contracts/io.js b/packages/contracts/io.js index a9962e9..3d9f8f6 100644 --- a/packages/contracts/io.js +++ b/packages/contracts/io.js @@ -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); } diff --git a/tests/integration/builder-ui.test.js b/tests/integration/builder-ui.test.js new file mode 100644 index 0000000..9f40434 --- /dev/null +++ b/tests/integration/builder-ui.test.js @@ -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 = ''; + const imageFile = new windowObject.File([svg], "machine.svg", { type: "image/svg+xml" }); + const staleSvg = ''; + 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(); + } +}); diff --git a/tests/test-runner.js b/tests/test-runner.js index 64dc80f..622ac0e 100644 --- a/tests/test-runner.js +++ b/tests/test-runner.js @@ -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: "테스트 모듈을 불러온다", diff --git a/tests/unit/acoustics.test.js b/tests/unit/acoustics.test.js index 932131a..ce2a88f 100644 --- a/tests/unit/acoustics.test.js +++ b/tests/unit/acoustics.test.js @@ -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}`); + }); +}); diff --git a/tests/unit/builder.test.js b/tests/unit/builder.test.js new file mode 100644 index 0000000..63dbb3b --- /dev/null +++ b/tests/unit/builder.test.js @@ -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"); +});