feat: play moving-source acoustic timelines

This commit is contained in:
Codex
2026-08-28 16:23:26 +09:00
parent 01a37eac69
commit a54aa25cd9
8 changed files with 665 additions and 8 deletions
+21
View File
@@ -55,6 +55,27 @@
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
</div>
<section class="live-panel" aria-labelledby="live-title">
<div class="live-heading">
<div>
<p class="eyebrow">Moving-source timeline</p>
<h2 id="live-title">이동 음원 라이브</h2>
<p id="live-status" aria-live="polite">trajectory가 있는 Scene을 불러오거나 이동 예제를 여세요.</p>
</div>
<div class="live-actions">
<button class="button button--ghost" id="load-moving-example-button" type="button">이동 예제</button>
<button class="button button--primary" id="prepare-live-button" type="button" disabled>라이브 준비</button>
<button class="button button--ghost" id="live-play-button" type="button" disabled>▶ 재생</button>
</div>
</div>
<div class="live-controls">
<label class="live-scrubber"><span id="live-time-label">0.00 / 0.00 s</span><input id="live-time" type="range" min="0" max="0" step="0.001" value="0" disabled /></label>
<label class="compact-select"><span>Map rate</span><select id="live-map-rate"><option value="2" selected>2 Hz</option><option value="5">5 Hz</option><option value="8">8 Hz</option></select></label>
<label class="compact-select"><span>속도</span><select id="live-speed"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="2">2×</option></select></label>
</div>
<p class="live-note">짧은 분석창의 중앙 위치를 사용하는 준정적 미리보기입니다. Heatmap은 계산된 frame 사이를 보간하고 위치 표시는 연속 이동합니다. 시뮬레이션 장면 재생이며 실제 마이크 스트림 입력은 아닙니다.</p>
</section>
<section class="result-section" aria-live="polite">
<div class="section-heading"><div><p class="eyebrow">Evaluation</p><h2>실행 결과</h2></div><span class="result-chip" id="result-badge">아직 실행하지 않음</span></div>
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
+455 -7
View File
@@ -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);
+18 -1
View File
@@ -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; }
}
+2
View File
@@ -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를 바꿀 때는 <http://127.0.0.1:4173/tests/>의
@@ -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
}
}
@@ -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": []
}
+91
View File
@@ -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();
}
});
+18
View File
@@ -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");