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