From a54aa25cd907eea6ddf13a8ebd03287dd0555af2 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 28 Aug 2026 16:23:26 +0900 Subject: [PATCH] feat: play moving-source acoustic timelines --- apps/acoustic-camera/index.html | 21 + apps/acoustic-camera/main.js | 462 +++++++++++++++++- apps/acoustic-camera/styles.css | 19 +- assets/sample-scenes/README.md | 2 + .../sample-scenes/moving-tone.experiment.json | 28 ++ assets/sample-scenes/moving-tone.scene.json | 32 ++ tests/integration/camera-ui.test.js | 91 ++++ tests/unit/contracts.test.js | 18 + 8 files changed, 665 insertions(+), 8 deletions(-) create mode 100644 assets/sample-scenes/moving-tone.experiment.json create mode 100644 assets/sample-scenes/moving-tone.scene.json diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html index c81943d..0a76d88 100644 --- a/apps/acoustic-camera/index.html +++ b/apps/acoustic-camera/index.html @@ -55,6 +55,27 @@ +
+
+
+

Moving-source timeline

+

이동 음원 라이브

+

trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.

+
+
+ + + +
+
+
+ + + +
+

짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. Heatmap은 계산된 frame 사이를 보간하고 위치 표시는 연속 이동합니다. 시뮬레이션 장면 재생이며 실제 마이크 스트림 입력은 아닙니다.

+
+

Evaluation

실행 결과

아직 실행하지 않음
시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.
diff --git a/apps/acoustic-camera/main.js b/apps/acoustic-camera/main.js index 977d599..ba900fd 100644 --- a/apps/acoustic-camera/main.js +++ b/apps/acoustic-camera/main.js @@ -7,6 +7,12 @@ import { validateScene, } from "/packages/contracts/validation.js"; import { createCameraAssetRuntime } from "./asset-runtime.js"; +import { + createAnalysisFramePlan, + playbackFrameAtTime, + sceneHasTrajectories, + scenePlaybackState, +} from "./timeline.js"; import { alignExperimentToScene, canonicalStringify, @@ -24,12 +30,19 @@ const DEFAULT_DOCUMENT_URLS = { scene: "/assets/sample-scenes/single-tone.scene.json", experiment: "/assets/sample-scenes/single-tone.experiment.json", }; +const MOVING_EXAMPLE_URLS = { + arrayDefinition: "/assets/array-geometries/rectangular-16x12.array.json", + scene: "/assets/sample-scenes/moving-tone.scene.json", + experiment: "/assets/sample-scenes/moving-tone.experiment.json", +}; const ARRAY_PRESET_URLS = new Map([ ["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"], ["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.array.json"], ]); const BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"]; const MAX_HISTORY_ITEMS = 24; +const MAX_LIVE_ANALYSIS_FRAMES = 48; +const MAX_LIVE_SCAN_POINTS = 2500; const byId = (id) => document.getElementById(id); const elements = { @@ -50,6 +63,15 @@ const elements = { overlayOpacity: byId("overlay-opacity"), showBeamSpread: byId("show-beam-spread"), showTruth: byId("show-truth"), + livePanel: document.querySelector(".live-panel"), + liveStatus: byId("live-status"), + loadMovingExampleButton: byId("load-moving-example-button"), + prepareLiveButton: byId("prepare-live-button"), + livePlayButton: byId("live-play-button"), + liveTime: byId("live-time"), + liveTimeLabel: byId("live-time-label"), + liveMapRate: byId("live-map-rate"), + liveSpeed: byId("live-speed"), resultBadge: byId("result-badge"), emptyResult: byId("empty-result"), metricGrid: byId("metric-grid"), @@ -101,6 +123,8 @@ const state = { validation: { valid: false, issues: [] }, visibleBandIds: new Set(), lastRun: null, + live: null, + liveError: null, history: [], active: null, pendingInputOperations: 0, @@ -180,11 +204,80 @@ function setTransientError(error, prefix = "오류") { elements.validationMessage.textContent = message; } +function stopLivePlayback(render = true) { + const live = state.live; + if (!live) return; + if (live.animationFrameId) cancelAnimationFrame(live.animationFrameId); + live.animationFrameId = 0; + live.playing = false; + live.playbackStartedAt = null; + if (render) renderLiveControls(); +} + +function resetLiveTimeline() { + stopLivePlayback(false); + state.live = null; + state.liveError = null; + renderLiveControls(); +} + +function renderLiveControls() { + const hasTrajectory = sceneHasTrajectories(state.scene); + const ready = Boolean(state.live?.frames?.length); + const preparing = state.active?.kind === "live"; + const playing = Boolean(ready && state.live.playing); + const locked = Boolean(state.active) || state.pendingInputOperations > 0; + elements.loadMovingExampleButton.disabled = locked; + elements.prepareLiveButton.disabled = locked || !hasTrajectory || !state.validation.valid; + elements.livePlayButton.disabled = locked || !ready; + elements.liveTime.disabled = locked || !ready; + elements.liveMapRate.disabled = locked; + elements.liveSpeed.disabled = locked || !ready; + elements.livePlayButton.textContent = playing ? "❚❚ 일시정지" : "▶ 재생"; + elements.livePanel.classList.toggle("is-ready", ready); + elements.livePanel.classList.toggle("is-playing", playing); + + if (ready) { + const time = Math.min(state.live.plan.duration, Math.max(0, state.live.time)); + elements.liveTime.max = String(state.live.plan.duration); + elements.liveTime.value = String(time); + elements.liveTimeLabel.textContent = + `${formatNumber(time, 2)} / ${formatNumber(state.live.plan.duration, 2)} s`; + elements.liveStatus.textContent = + `${state.live.frames.length}개 acoustic map · ${formatNumber(state.live.plan.effectiveMapHz, 1)} Hz · ` + + `${state.live.snapshot.experiment.scan.columns}×${state.live.snapshot.experiment.scan.rows}` + + `${state.live.reducedScan ? " preview" : ""} · ${state.live.backend.toUpperCase()}`; + } else { + elements.liveTime.max = String(state.scene?.duration ?? 0); + elements.liveTime.value = "0"; + elements.liveTimeLabel.textContent = `0.00 / ${formatNumber(state.scene?.duration ?? 0, 2)} s`; + if (preparing) { + elements.liveStatus.textContent = "이동 음원의 시간창 acoustic map을 계산하고 있습니다…"; + } else if (state.liveError) { + elements.liveStatus.textContent = `라이브 준비 실패 · ${state.liveError}`; + } else if (hasTrajectory) { + elements.liveStatus.textContent = "이동 경로를 찾았습니다. ‘라이브 준비’를 누르면 시간축 map을 계산합니다."; + } else { + elements.liveStatus.textContent = "trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요."; + } + } + let liveState = "unavailable"; + if (hasTrajectory) liveState = "idle"; + if (state.liveError) liveState = "error"; + if (ready) liveState = "ready"; + if (playing) liveState = "playing"; + if (preparing) liveState = "preparing"; + document.documentElement.dataset.liveState = liveState; + document.documentElement.dataset.liveFrameCount = ready ? String(state.live.frames.length) : "0"; + document.documentElement.dataset.liveTime = ready ? String(state.live.time) : "0"; +} + function setConfigurationLocked(locked) { setEditableControlsDisabled(locked); elements.runButton.disabled = locked || !state.validation.valid; elements.cancelButton.classList.toggle("is-hidden", !locked); elements.exportResultButton.disabled = locked || !state.lastRun; + renderLiveControls(); } function setEditableControlsDisabled(disabled) { @@ -202,6 +295,7 @@ function beginInputOperation() { document.querySelectorAll(".workspace-tools input").forEach((input) => { input.disabled = true; }); + renderLiveControls(); } function endInputOperation() { @@ -216,6 +310,7 @@ function endInputOperation() { input.disabled = false; }); } + renderLiveControls(); scheduleValidation(); } @@ -228,6 +323,7 @@ function markChanged(options = {}) { if (dirty === "scene" || dirty === "both") state.sceneDirty = true; if (dirty === "experiment" || dirty === "both") state.experimentDirty = true; state.revision += 1; + resetLiveTimeline(); if (state.lastRun) state.lastRun.stale = true; if (["error", "canceled"].includes(document.documentElement.dataset.runState)) { document.documentElement.dataset.runState = "idle"; @@ -299,7 +395,8 @@ function renderDocumentSummary() { const detail = document.createElement("span"); detail.textContent = `${describeSignal(source.signal)} · ` + - `(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m`; + `(${source.position.map((value) => formatNumber(value, 2)).join(", ")}) m` + + (source.trajectory?.length ? ` · 이동 ${source.trajectory.length} keyframes` : ""); line.append(name, detail); elements.sourceSummary.append(line); } @@ -419,8 +516,60 @@ function bandsForVisualization(lastRun) { })); } +function interpolateMap(left, right, progress) { + if (left === right || !(progress > 0)) return left; + if (!left || !right || left.length !== right.length) return progress < 0.5 ? left : right; + const values = new Float32Array(left.length); + const inverse = 1 - progress; + for (let index = 0; index < values.length; index += 1) { + values[index] = left[index] * inverse + right[index] * progress; + } + return values; +} + +function liveVisualizationState(live) { + const playback = scenePlaybackState(live.snapshot.scene, live.plan, live.time); + const leftFrame = live.frames[playback.leftFrameIndex]; + const rightFrame = live.frames[playback.rightFrameIndex]; + const nearestFrame = live.frames[playback.frameIndex]; + const rightBands = new Map(rightFrame.run.bands.map((band) => [band.id, band])); + const definitions = new Map( + live.snapshot.experiment.frequencyBands.map((band) => [band.id, band]), + ); + const bands = leftFrame.run.bands.map((leftBand) => { + const rightBand = rightBands.get(leftBand.id) ?? leftBand; + return { + ...leftBand, + displayMap: interpolateMap(leftBand.displayMap, rightBand.displayMap, playback.progress), + color: leftBand.color ?? definitions.get(leftBand.id)?.color ?? "#65f2bf", + visible: state.visibleBandIds.has(leftBand.id), + }; + }); + return { playback, scene: playback.scene, bands, evaluations: nearestFrame.evaluations }; +} + function renderSceneVisualization() { if (!state.scene || !state.experiment) return; + if (state.live?.frames?.length) { + const live = state.live; + const visualization = liveVisualizationState(live); + sceneVisualization.render({ + scene: visualization.scene, + experiment: live.snapshot.experiment, + image: live.imageElement, + bands: visualization.bands, + evaluations: visualization.evaluations, + dynamicRangeDb: live.snapshot.experiment.beamforming.dynamicRangeDb, + opacity: Number(elements.overlayOpacity.value), + showBeamSpread: elements.showBeamSpread.checked, + showTruth: elements.showTruth.checked, + }); + elements.canvasMessage.classList.add("is-hidden"); + elements.canvasBadge.textContent = + `LIVE · ${formatNumber(live.time, 2)} s · map ${visualization.playback.framePosition.toFixed(1)}/` + + `${Math.max(0, live.frames.length - 1)} · ${live.backend.toUpperCase()}`; + return; + } const displayed = state.lastRun; const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment }; sceneVisualization.render({ @@ -526,6 +675,16 @@ function renderResult() { "half-power equivalent diameter", band.color, ); + const spread = evaluation.detections?.find((detection) => detection.beamSpread)?.beamSpread; + if (spread?.ellipse) { + appendMetric( + `${band.id} · 빔 분포`, + `${formatNumber(spread.ellipse.majorRmsRadiusM * 100, 1)} × ` + + `${formatNumber(spread.ellipse.minorRmsRadiusM * 100, 1)} cm`, + `1× RMS · ${formatNumber(spread.ellipse.angleDeg, 0)}° · 확률 분포 아님`, + band.color, + ); + } } } @@ -565,8 +724,10 @@ function renderHistory() { function validationWarnings() { const warnings = []; if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings; - const zMismatch = state.scene.sources.some( - (source) => Math.abs(source.position[2] - state.experiment.scan.z) > 1e-6, + const zMismatch = state.scene.sources.some((source) => + [source.position, ...(source.trajectory ?? []).map(({ position }) => position)].some( + (position) => Math.abs(position[2] - state.experiment.scan.z) > 1e-6, + ), ); if (zMismatch) { warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다."); @@ -669,6 +830,7 @@ function applyValidation(validation) { } } } + renderLiveControls(); } function scheduleValidation() { @@ -680,8 +842,13 @@ function scheduleValidation() { }); } -function sourcesForBand(scene, band) { +function sourcesForBand(scene, band, time = null) { return scene.sources.filter((source) => { + if (time !== null) { + const startTime = source.startTime ?? 0; + const endTime = startTime + (source.duration ?? scene.duration); + if (time < startTime || time > endTime) return false; + } if (source.signal.type !== "tone") return true; return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz; }); @@ -705,7 +872,9 @@ function progressMessage(message) { return `계산 중 · ${message.stage ?? "worker"}`; } -function runWorker(activity, payload, transfer) { +function runWorker(activity, payload, transfer, resultOptions = {}) { + const resultType = resultOptions.type ?? "result"; + const resultKey = resultOptions.key ?? "run"; return new Promise((resolve, reject) => { const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" }); activity.worker = worker; @@ -716,12 +885,16 @@ function runWorker(activity, payload, transfer) { if (message.type === "progress") { document.documentElement.dataset.runStage = message.stage ?? "worker"; setRunStatus(progressMessage(message)); + if (activity.kind === "live") elements.liveStatus.textContent = progressMessage(message); } else if (message.type === "fallback") { activity.fallback = { code: message.code, message: message.message }; setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`); - } else if (message.type === "result") { + if (activity.kind === "live") { + elements.liveStatus.textContent = `WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`; + } + } else if (message.type === resultType) { activity.finished = true; - resolve(message.run); + resolve(message[resultKey]); } else if (message.type === "error") { activity.finished = true; const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다."); @@ -765,6 +938,7 @@ async function runSimulation() { state.resultDirty = false; syncUnsavedState(); } + resetLiveTimeline(); const snapshot = { arrayDefinition: structuredClone(state.arrayDefinition), scene: structuredClone(state.scene), @@ -772,6 +946,7 @@ async function runSimulation() { }; const activity = { jobId: `camera-${Date.now()}-${++jobSequence}`, + kind: "snapshot", revision: state.revision, abortController: new AbortController(), worker: null, @@ -898,6 +1073,260 @@ async function runSimulation() { } } +function createLiveExperiment(experiment) { + const preview = structuredClone(experiment); + const originalPoints = preview.scan.columns * preview.scan.rows; + if (originalPoints <= MAX_LIVE_SCAN_POINTS) { + return { experiment: preview, reduced: false, originalPoints }; + } + const scale = Math.sqrt(MAX_LIVE_SCAN_POINTS / originalPoints); + preview.scan.columns = Math.max(8, Math.floor(preview.scan.columns * scale)); + preview.scan.rows = Math.max(8, Math.floor(preview.scan.rows * scale)); + while (preview.scan.columns * preview.scan.rows > MAX_LIVE_SCAN_POINTS) { + if (preview.scan.columns >= preview.scan.rows && preview.scan.columns > 8) { + preview.scan.columns -= 1; + } else if (preview.scan.rows > 8) { + preview.scan.rows -= 1; + } else { + break; + } + } + return { experiment: preview, reduced: true, originalPoints }; +} + +function reconcileLivePlan(plan, timeline) { + const frames = timeline.frames.map(({ run, ...frame }) => frame); + const frameInterval = frames.length > 1 + ? (frames.at(-1).sampleTime - frames[0].sampleTime) / (frames.length - 1) + : plan.duration; + return { + ...plan, + frames, + frameCount: frames.length, + frameInterval, + effectiveMapHz: frameInterval > 0 ? 1 / frameInterval : 0, + }; +} + +function evaluateLiveFrames(snapshot, plan, frames) { + for (const frame of frames) { + const frameScene = scenePlaybackState(snapshot.scene, plan, frame.sampleTime).scene; + const evaluations = new Map(); + for (const band of frame.run.bands) { + const definition = snapshot.experiment.frequencyBands.find( + (candidate) => candidate.id === band.id, + ); + if (!definition) continue; + evaluations.set( + band.id, + evaluateDasBand({ + rawMap: band.rawMap, + scan: snapshot.experiment.scan, + sources: sourcesForBand(frameScene, definition, frame.sampleTime), + dynamicRangeDb: snapshot.experiment.beamforming.dynamicRangeDb, + }), + ); + } + frame.evaluations = evaluations; + } +} + +async function prepareLiveSimulation() { + if (state.active) return; + const validation = await validateCurrent(); + if (!validation.valid) { + state.liveError = validation.issues.slice(0, 2).map(formatIssue).join(" · "); + renderLiveControls(); + return; + } + if (!sceneHasTrajectories(state.scene)) { + state.liveError = "Scene에 2개 이상의 keyframe을 가진 trajectory가 없습니다."; + renderLiveControls(); + return; + } + if (validation.revision !== state.revision || state.pendingInputOperations > 0) { + state.liveError = "입력 갱신이 끝난 뒤 다시 시도하세요."; + renderLiveControls(); + await validateCurrent(); + return; + } + + resetLiveTimeline(); + const preview = createLiveExperiment(state.experiment); + const snapshot = { + arrayDefinition: structuredClone(state.arrayDefinition), + scene: structuredClone(state.scene), + experiment: preview.experiment, + }; + const plan = createAnalysisFramePlan(snapshot.scene, { + fftSize: snapshot.experiment.stft.fftSize, + targetMapHz: Number(elements.liveMapRate.value), + maxFrames: MAX_LIVE_ANALYSIS_FRAMES, + }); + const activity = { + jobId: `camera-live-${Date.now()}-${++jobSequence}`, + kind: "live", + revision: state.revision, + abortController: new AbortController(), + worker: null, + reject: null, + canceled: false, + finished: false, + }; + state.active = activity; + state.liveError = null; + document.documentElement.dataset.runStage = "live-assets"; + setConfigurationLocked(true); + elements.prepareLiveButton.textContent = "준비 중…"; + setRunStatus(`라이브 입력 고정 · ${plan.frameCount}개 map을 준비합니다…`); + try { + const assetBuffers = await assetRuntime.decodeAudioAssets( + snapshot.scene, + activity.abortController.signal, + ({ completed, total, fileName }) => { + if (state.active !== activity) return; + elements.liveStatus.textContent = total > 0 + ? `오디오 decode · ${completed}/${total}${fileName ? ` · ${fileName}` : ""}` + : "Synthetic signal 준비 완료"; + }, + ); + if (activity.canceled || state.active !== activity) throw createAbortError(); + document.documentElement.dataset.runStage = "live-worker"; + const timeline = await runWorker( + activity, + { + type: "run-timeline", + jobId: activity.jobId, + scene: snapshot.scene, + arrayDefinition: snapshot.arrayDefinition, + experiment: snapshot.experiment, + framePlan: plan, + assetBuffers, + }, + assetRuntime.transferListForAudio(assetBuffers), + { type: "timeline-result", key: "timeline" }, + ); + if (activity.canceled || state.active !== activity) throw createAbortError(); + elements.liveStatus.textContent = "프레임별 peak와 빔 분포를 계산하고 있습니다…"; + const resolvedPlan = reconcileLivePlan(plan, timeline); + evaluateLiveFrames(snapshot, resolvedPlan, timeline.frames); + state.live = { + snapshot, + plan: resolvedPlan, + frames: timeline.frames, + backend: timeline.backend, + timingMs: timeline.timingMs, + reducedScan: preview.reduced, + originalPoints: preview.originalPoints, + imageElement: assetRuntime.imageElement, + time: 0, + playing: false, + animationFrameId: 0, + playbackStartedAt: null, + playbackStartedTime: 0, + }; + setRunStatus( + `라이브 준비 완료 · ${timeline.frames.length} maps · ` + + `${formatNumber(timeline.timingMs.total, 0)} ms`, + ); + renderLiveControls(); + renderSceneVisualization(); + } catch (error) { + if (error?.name === "AbortError" || activity.canceled) { + setRunStatus("라이브 map 준비를 취소했습니다."); + } else { + state.liveError = error instanceof Error ? error.message : String(error); + setRunStatus(`라이브 준비 실패 · ${state.liveError}`); + } + } finally { + activity.worker?.terminate(); + if (state.active === activity) state.active = null; + elements.prepareLiveButton.textContent = "라이브 준비"; + setConfigurationLocked(false); + renderBandEditors(); + await validateCurrent(); + renderLiveControls(); + } +} + +function setLiveTime(value) { + const live = state.live; + if (!live) return; + live.time = Math.min(live.plan.duration, Math.max(0, Number(value) || 0)); + const playback = playbackFrameAtTime(live.plan, live.time); + document.documentElement.dataset.liveFrame = String(playback.framePosition); + renderLiveControls(); + renderSceneVisualization(); +} + +function livePlaybackTick(timestamp) { + const live = state.live; + if (!live?.playing) return; + if (live.playbackStartedAt === null) live.playbackStartedAt = timestamp; + const speed = Number(elements.liveSpeed.value) || 1; + const elapsed = ((timestamp - live.playbackStartedAt) / 1000) * speed; + const nextTime = live.playbackStartedTime + elapsed; + if (nextTime >= live.plan.duration) { + live.time = live.plan.duration; + stopLivePlayback(false); + renderLiveControls(); + renderSceneVisualization(); + return; + } + setLiveTime(nextTime); + live.animationFrameId = requestAnimationFrame(livePlaybackTick); +} + +function toggleLivePlayback() { + const live = state.live; + if (!live) return; + if (live.playing) { + stopLivePlayback(); + return; + } + if (live.time >= live.plan.duration) live.time = 0; + live.playing = true; + live.playbackStartedAt = null; + live.playbackStartedTime = live.time; + live.animationFrameId = requestAnimationFrame(livePlaybackTick); + renderLiveControls(); + renderSceneVisualization(); +} + +async function loadMovingExample() { + if (state.active) return; + beginInputOperation(); + try { + setRunStatus("이동 음원 예제를 불러오고 있습니다…"); + const [arrayDefinition, scene, experiment] = await Promise.all([ + loadJson(MOVING_EXAMPLE_URLS.arrayDefinition), + loadJson(MOVING_EXAMPLE_URLS.scene), + loadJson(MOVING_EXAMPLE_URLS.experiment), + ]); + const [arrayIssues, sceneIssues] = await Promise.all([ + validateArrayDefinition(arrayDefinition), + validateScene(scene), + ]); + const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene }); + const issues = [...arrayIssues, ...sceneIssues, ...experimentIssues]; + if (issues.length > 0) throw new Error(issues.slice(0, 4).map(formatIssue).join(" · ")); + state.arrayDefinition = arrayDefinition; + state.scene = scene; + state.experiment = experiment; + state.sceneDirty = false; + state.experimentDirty = false; + state.visibleBandIds = new Set(experiment.frequencyBands.map((band) => band.id)); + assetRuntime.reset(); + markChanged({ syncControls: true, renderBands: true, dirty: "none" }); + renderAssets(); + setRunStatus("이동 음원 예제를 열었습니다. ‘라이브 준비’를 누르세요."); + } catch (error) { + setTransientError(error, "이동 예제 불러오기 실패"); + } finally { + endInputOperation(); + } +} + function cancelSimulation() { const activity = state.active; if (!activity || activity.canceled) return; @@ -1127,6 +1556,22 @@ function installBandListeners() { function installActionListeners() { elements.runButton.addEventListener("click", runSimulation); elements.cancelButton.addEventListener("click", cancelSimulation); + elements.loadMovingExampleButton.addEventListener("click", loadMovingExample); + elements.prepareLiveButton.addEventListener("click", prepareLiveSimulation); + elements.livePlayButton.addEventListener("click", toggleLivePlayback); + elements.liveTime.addEventListener("input", () => { + stopLivePlayback(false); + setLiveTime(elements.liveTime.valueAsNumber); + }); + elements.liveMapRate.addEventListener("change", () => { + resetLiveTimeline(); + renderSceneVisualization(); + }); + elements.liveSpeed.addEventListener("change", () => { + if (!state.live?.playing) return; + state.live.playbackStartedAt = performance.now(); + state.live.playbackStartedTime = state.live.time; + }); elements.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput)); elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput)); elements.experimentInput.addEventListener( @@ -1229,6 +1674,7 @@ function installActionListeners() { event.returnValue = ""; }); window.addEventListener("unload", () => { + stopLivePlayback(false); cancelSimulation(); sceneVisualization.destroy(); arrayVisualization.destroy(); @@ -1242,6 +1688,7 @@ async function initialize() { installActionListeners(); setEditableControlsDisabled(true); document.documentElement.dataset.runState = "idle"; + renderLiveControls(); try { const [arrayDefinition, scene, experiment] = await Promise.all([ loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition), @@ -1261,6 +1708,7 @@ async function initialize() { renderResult(); renderHistory(); renderSceneVisualization(); + renderLiveControls(); syncUnsavedState(); await validateCurrent(); setEditableControlsDisabled(false); diff --git a/apps/acoustic-camera/styles.css b/apps/acoustic-camera/styles.css index 0141d57..397a55e 100644 --- a/apps/acoustic-camera/styles.css +++ b/apps/acoustic-camera/styles.css @@ -35,6 +35,18 @@ .range-field, .check-field { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: .7rem; font-weight: 650; } .range-field input { width: 110px; accent-color: var(--mint); } .check-field input { width: 17px; height: 17px; accent-color: var(--mint); } +.live-panel { margin-top: 12px; padding: 15px 16px; border: 1px solid rgb(88 199 255 / 24%); border-radius: 16px; background: linear-gradient(135deg, rgb(88 199 255 / 7%), rgb(101 242 191 / 3%)); } +.live-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.live-heading h2 { margin: 0; font-size: .98rem; } +.live-heading p:not(.eyebrow) { margin: 5px 0 0; color: var(--muted); font-size: .7rem; } +.live-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.live-controls { display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 12px; align-items: end; margin-top: 14px; } +.live-scrubber, .compact-select { display: grid; gap: 6px; color: var(--muted); font-size: .66rem; font-weight: 650; } +.live-scrubber input { width: 100%; accent-color: var(--cyan); } +.compact-select select { min-height: 34px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: #0b1714; } +.live-note { margin: 10px 0 0; color: var(--muted); font-size: .64rem; line-height: 1.55; } +.live-panel.is-ready { border-color: rgb(101 242 191 / 42%); } +.live-panel.is-playing { box-shadow: inset 3px 0 0 var(--cyan); } .result-section, .history-section { margin-top: 28px; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: rgb(255 255 255 / 2%); } .section-heading h2 { margin: 0; font-size: 1.05rem; } .result-chip { padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .68rem; } @@ -119,13 +131,18 @@ .header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); } .scene-workspace { padding: 18px; } .workspace-heading { flex-direction: column; } + .live-heading { align-items: stretch; flex-direction: column; } + .live-actions { justify-content: flex-start; } + .live-controls { grid-template-columns: 1fr 1fr; } + .live-scrubber { grid-column: 1 / -1; } .scene-canvas-card { min-height: 380px; } #scene-canvas { height: 420px; } .history-card { grid-template-columns: 1fr 1fr; } .history-card strong { grid-column: 1 / -1; } } @media (max-width: 430px) { - .field-grid, .band-editor, .output-actions { grid-template-columns: 1fr; } + .field-grid, .band-editor, .output-actions, .live-controls { grid-template-columns: 1fr; } + .live-scrubber { grid-column: auto; } .band-editor__remove { min-height: 34px; } .metric-grid { grid-template-columns: 1fr 1fr; } } diff --git a/assets/sample-scenes/README.md b/assets/sample-scenes/README.md index 82fcde9..076b6b6 100644 --- a/assets/sample-scenes/README.md +++ b/assets/sample-scenes/README.md @@ -6,6 +6,8 @@ - `single-tone.scene.json`: 48 kHz, 0.25 s, 1 kHz tone 하나를 z=2 m 평면에 배치한 Scene - `single-tone.experiment.json`: rectangular 192채널 배열, 900–1100 Hz band, 50×50 scan, FFT 512 / hop 256, Auto backend를 쓰는 Experiment +- `moving-tone.scene.json`: 2초 동안 다섯 trajectory keyframe을 따라 이동하는 2 kHz tone Scene +- `moving-tone.experiment.json`: 라이브 준비 시간을 줄인 24×18 scan, FFT 256 / hop 128 Experiment 두 문서는 계약 버전 `1.0.0`이며 기본 Camera 실행, CPU golden test, CPU/WebGPU 비교와 benchmark가 같은 조건을 공유하도록 고정되어 있습니다. fixture를 바꿀 때는 의 diff --git a/assets/sample-scenes/moving-tone.experiment.json b/assets/sample-scenes/moving-tone.experiment.json new file mode 100644 index 0000000..e3602b4 --- /dev/null +++ b/assets/sample-scenes/moving-tone.experiment.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "1.0.0", + "kind": "micarrsim.experiment", + "id": "moving-tone-live", + "name": "2 kHz 이동 음원 · 라이브 미리보기", + "arrayId": "rectangular-16x12", + "sceneId": "moving-tone", + "scan": { + "z": 2, + "xMin": -0.6, + "xMax": 0.6, + "yMin": -0.45, + "yMax": 0.45, + "columns": 24, + "rows": 18 + }, + "frequencyBands": [ + { "id": "tone-2k", "minHz": 1850, "maxHz": 2150, "color": "#58c7ff" } + ], + "stft": { "fftSize": 256, "hopSize": 128, "window": "hann" }, + "beamforming": { + "algorithm": "das", + "backend": "auto", + "minimumDistance": 0.01, + "normalization": "peak", + "dynamicRangeDb": 30 + } +} diff --git a/assets/sample-scenes/moving-tone.scene.json b/assets/sample-scenes/moving-tone.scene.json new file mode 100644 index 0000000..0fde16c --- /dev/null +++ b/assets/sample-scenes/moving-tone.scene.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": "1.0.0", + "kind": "micarrsim.scene", + "id": "moving-tone", + "name": "이동하는 2 kHz 음원", + "description": "라이브 타임라인과 trajectory 편집을 바로 확인하는 자유음장 예제", + "units": "m", + "sampleRate": 8000, + "duration": 2, + "speedOfSound": 343, + "seed": 20260828, + "mix": { "normalization": "none", "targetPeak": 0.95 }, + "sources": [ + { + "id": "moving-source-1", + "name": "움직이는 2 kHz tone", + "position": [-0.45, -0.2, 2], + "trajectory": [ + { "time": 0, "position": [-0.45, -0.2, 2] }, + { "time": 0.5, "position": [-0.22, 0.18, 2] }, + { "time": 1, "position": [0, 0.3, 2] }, + { "time": 1.5, "position": [0.24, 0.12, 2] }, + { "time": 2, "position": [0.45, -0.2, 2] } + ], + "gainDb": -6, + "startTime": 0, + "duration": 2, + "signal": { "type": "tone", "frequency": 2000, "phaseDeg": 0 } + } + ], + "ambient": [] +} diff --git a/tests/integration/camera-ui.test.js b/tests/integration/camera-ui.test.js index 1b83526..bd502c7 100644 --- a/tests/integration/camera-ui.test.js +++ b/tests/integration/camera-ui.test.js @@ -209,3 +209,94 @@ test("Acoustic Camera가 8×8 CPU 실행·결과 저장·stale·재실행 흐름 frame.remove(); } }); + +test("Acoustic Camera prepares, scrubs, plays, and pauses the moving-source example", async () => { + const frame = document.createElement("iframe"); + frame.src = "/apps/acoustic-camera/?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("Acoustic Camera live iframe load failed")), { + once: true, + }); + }); + + const windowObject = frame.contentWindow; + const documentObject = frame.contentDocument; + try { + await waitFor( + () => documentObject.documentElement.dataset.appReady === "true", + "Acoustic Camera live initialization", + ); + + documentObject.querySelector("#load-moving-example-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.inputBusy === "false" && + documentObject.documentElement.dataset.cameraValid === "true" && + documentObject.documentElement.dataset.liveState === "idle" && + documentObject.querySelector("#experiment-id").value === "moving-tone-live" && + !documentObject.querySelector("#prepare-live-button").disabled, + "valid moving example trajectory", + ); + + dispatchValue(windowObject, documentObject.querySelector("#scan-columns"), 8); + dispatchValue(windowObject, documentObject.querySelector("#scan-rows"), 8); + dispatchValue(windowObject, documentObject.querySelector("#backend"), "cpu"); + dispatchValue(windowObject, documentObject.querySelector("#live-map-rate"), 2); + await waitFor( + () => documentObject.documentElement.dataset.cameraValid === "true" && + documentObject.documentElement.dataset.liveState === "idle" && + documentObject.querySelector("#scan-columns").value === "8" && + documentObject.querySelector("#scan-rows").value === "8" && + documentObject.querySelector("#backend").value === "cpu" && + documentObject.querySelector("#live-map-rate").value === "2" && + !documentObject.querySelector("#prepare-live-button").disabled, + "8x8 CPU 2 Hz live configuration", + ); + + documentObject.querySelector("#prepare-live-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.liveState === "ready", + "moving-source live preparation", + 45000, + ); + const frameCount = Number(documentObject.documentElement.dataset.liveFrameCount); + assert(frameCount > 1, `multiple live frames: ${frameCount}`); + assert(documentObject.querySelector("#live-status").textContent.includes("CPU"), "live backend is CPU"); + + dispatchValue(windowObject, documentObject.querySelector("#live-time"), 0.6, "input"); + await waitFor( + () => Number(documentObject.documentElement.dataset.liveTime) >= 0.599, + "live scrub to a later time", + ); + const scrubbedTime = Number(documentObject.documentElement.dataset.liveTime); + assert(Number(documentObject.documentElement.dataset.liveFrame) > 0, "scrub selects a later map frame"); + + documentObject.querySelector("#live-play-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.liveState === "playing", + "live playback start", + ); + await waitFor( + () => Number(documentObject.documentElement.dataset.liveTime) > scrubbedTime + 0.05, + "live playback time advance", + 5000, + ); + + documentObject.querySelector("#live-play-button").click(); + await waitFor( + () => documentObject.documentElement.dataset.liveState === "ready", + "live playback pause", + ); + const pausedTime = Number(documentObject.documentElement.dataset.liveTime); + await new Promise((resolve) => setTimeout(resolve, 120)); + const timeAfterPause = Number(documentObject.documentElement.dataset.liveTime); + assert( + Math.abs(timeAfterPause - pausedTime) <= 0.01, + `paused live time remains stable: ${pausedTime} -> ${timeAfterPause}`, + ); + } finally { + frame.remove(); + } +}); diff --git a/tests/unit/contracts.test.js b/tests/unit/contracts.test.js index de94e64..9d0e81d 100644 --- a/tests/unit/contracts.test.js +++ b/tests/unit/contracts.test.js @@ -11,6 +11,24 @@ async function loadJson(path) { return response.json(); } +test("moving-tone sample Scene and Experiment satisfy their linked contracts", async () => { + const [arrayDefinition, scene, experiment] = await Promise.all([ + loadJson("/assets/array-geometries/rectangular-16x12.array.json"), + loadJson("/assets/sample-scenes/moving-tone.scene.json"), + loadJson("/assets/sample-scenes/moving-tone.experiment.json"), + ]); + const sceneIssues = await validateScene(scene); + const experimentIssues = await validateExperiment(experiment, { arrayDefinition, scene }); + + assertEqual(sceneIssues.length, 0, `moving Scene issues: ${JSON.stringify(sceneIssues)}`); + assertEqual(experimentIssues.length, 0, `moving Experiment issues: ${JSON.stringify(experimentIssues)}`); + assertEqual(experiment.sceneId, scene.id, "moving example linkage"); + assert( + scene.sources.some((source) => Array.isArray(source.trajectory) && source.trajectory.length >= 2), + "moving example contains a usable trajectory", + ); +}); + test("192채널 기준 배열이 계약을 통과한다", async () => { const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json"); assertEqual(arrayDefinition.microphones.length, 192, "microphone count");