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 @@ Ground truth + + + + Moving-source timeline + 이동 음원 라이브 + trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요. + + + 이동 예제 + 라이브 준비 + ▶ 재생 + + + + 0.00 / 0.00 s + Map rate2 Hz5 Hz8 Hz + 속도0.5×1×2× + + 짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. 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");
Moving-source timeline
trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.
짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. Heatmap은 계산된 frame 사이를 보간하고 위치 표시는 연속 이동합니다. 시뮬레이션 장면 재생이며 실제 마이크 스트림 입력은 아닙니다.
Evaluation