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
+
-
-