feat: play moving-source acoustic timelines
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user