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>
|
<label class="check-field"><input id="show-truth" type="checkbox" checked /><span>Ground truth</span></label>
|
||||||
</div>
|
</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">
|
<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="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>
|
<div class="empty-result" id="empty-result">시뮬레이션을 실행하면 위치 오차, energy concentration, sidelobe와 timing을 표시합니다.</div>
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
validateScene,
|
validateScene,
|
||||||
} from "/packages/contracts/validation.js";
|
} from "/packages/contracts/validation.js";
|
||||||
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
import { createCameraAssetRuntime } from "./asset-runtime.js";
|
||||||
|
import {
|
||||||
|
createAnalysisFramePlan,
|
||||||
|
playbackFrameAtTime,
|
||||||
|
sceneHasTrajectories,
|
||||||
|
scenePlaybackState,
|
||||||
|
} from "./timeline.js";
|
||||||
import {
|
import {
|
||||||
alignExperimentToScene,
|
alignExperimentToScene,
|
||||||
canonicalStringify,
|
canonicalStringify,
|
||||||
@@ -24,12 +30,19 @@ const DEFAULT_DOCUMENT_URLS = {
|
|||||||
scene: "/assets/sample-scenes/single-tone.scene.json",
|
scene: "/assets/sample-scenes/single-tone.scene.json",
|
||||||
experiment: "/assets/sample-scenes/single-tone.experiment.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([
|
const ARRAY_PRESET_URLS = new Map([
|
||||||
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
|
["rectangular-16x12", "/assets/array-geometries/rectangular-16x12.array.json"],
|
||||||
["fermat-spiral-192", "/assets/array-geometries/fermat-spiral-192.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 BAND_COLORS = ["#65f2bf", "#58c7ff", "#ff7f8f", "#ffc56e", "#b294ff", "#8fe388", "#ff9ee5", "#83a8ff"];
|
||||||
const MAX_HISTORY_ITEMS = 24;
|
const MAX_HISTORY_ITEMS = 24;
|
||||||
|
const MAX_LIVE_ANALYSIS_FRAMES = 48;
|
||||||
|
const MAX_LIVE_SCAN_POINTS = 2500;
|
||||||
|
|
||||||
const byId = (id) => document.getElementById(id);
|
const byId = (id) => document.getElementById(id);
|
||||||
const elements = {
|
const elements = {
|
||||||
@@ -50,6 +63,15 @@ const elements = {
|
|||||||
overlayOpacity: byId("overlay-opacity"),
|
overlayOpacity: byId("overlay-opacity"),
|
||||||
showBeamSpread: byId("show-beam-spread"),
|
showBeamSpread: byId("show-beam-spread"),
|
||||||
showTruth: byId("show-truth"),
|
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"),
|
resultBadge: byId("result-badge"),
|
||||||
emptyResult: byId("empty-result"),
|
emptyResult: byId("empty-result"),
|
||||||
metricGrid: byId("metric-grid"),
|
metricGrid: byId("metric-grid"),
|
||||||
@@ -101,6 +123,8 @@ const state = {
|
|||||||
validation: { valid: false, issues: [] },
|
validation: { valid: false, issues: [] },
|
||||||
visibleBandIds: new Set(),
|
visibleBandIds: new Set(),
|
||||||
lastRun: null,
|
lastRun: null,
|
||||||
|
live: null,
|
||||||
|
liveError: null,
|
||||||
history: [],
|
history: [],
|
||||||
active: null,
|
active: null,
|
||||||
pendingInputOperations: 0,
|
pendingInputOperations: 0,
|
||||||
@@ -180,11 +204,80 @@ function setTransientError(error, prefix = "오류") {
|
|||||||
elements.validationMessage.textContent = message;
|
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) {
|
function setConfigurationLocked(locked) {
|
||||||
setEditableControlsDisabled(locked);
|
setEditableControlsDisabled(locked);
|
||||||
elements.runButton.disabled = locked || !state.validation.valid;
|
elements.runButton.disabled = locked || !state.validation.valid;
|
||||||
elements.cancelButton.classList.toggle("is-hidden", !locked);
|
elements.cancelButton.classList.toggle("is-hidden", !locked);
|
||||||
elements.exportResultButton.disabled = locked || !state.lastRun;
|
elements.exportResultButton.disabled = locked || !state.lastRun;
|
||||||
|
renderLiveControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEditableControlsDisabled(disabled) {
|
function setEditableControlsDisabled(disabled) {
|
||||||
@@ -202,6 +295,7 @@ function beginInputOperation() {
|
|||||||
document.querySelectorAll(".workspace-tools input").forEach((input) => {
|
document.querySelectorAll(".workspace-tools input").forEach((input) => {
|
||||||
input.disabled = true;
|
input.disabled = true;
|
||||||
});
|
});
|
||||||
|
renderLiveControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function endInputOperation() {
|
function endInputOperation() {
|
||||||
@@ -216,6 +310,7 @@ function endInputOperation() {
|
|||||||
input.disabled = false;
|
input.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
renderLiveControls();
|
||||||
scheduleValidation();
|
scheduleValidation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +323,7 @@ function markChanged(options = {}) {
|
|||||||
if (dirty === "scene" || dirty === "both") state.sceneDirty = true;
|
if (dirty === "scene" || dirty === "both") state.sceneDirty = true;
|
||||||
if (dirty === "experiment" || dirty === "both") state.experimentDirty = true;
|
if (dirty === "experiment" || dirty === "both") state.experimentDirty = true;
|
||||||
state.revision += 1;
|
state.revision += 1;
|
||||||
|
resetLiveTimeline();
|
||||||
if (state.lastRun) state.lastRun.stale = true;
|
if (state.lastRun) state.lastRun.stale = true;
|
||||||
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
|
if (["error", "canceled"].includes(document.documentElement.dataset.runState)) {
|
||||||
document.documentElement.dataset.runState = "idle";
|
document.documentElement.dataset.runState = "idle";
|
||||||
@@ -299,7 +395,8 @@ function renderDocumentSummary() {
|
|||||||
const detail = document.createElement("span");
|
const detail = document.createElement("span");
|
||||||
detail.textContent =
|
detail.textContent =
|
||||||
`${describeSignal(source.signal)} · ` +
|
`${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);
|
line.append(name, detail);
|
||||||
elements.sourceSummary.append(line);
|
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() {
|
function renderSceneVisualization() {
|
||||||
if (!state.scene || !state.experiment) return;
|
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 displayed = state.lastRun;
|
||||||
const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment };
|
const snapshot = displayed?.snapshot ?? { scene: state.scene, experiment: state.experiment };
|
||||||
sceneVisualization.render({
|
sceneVisualization.render({
|
||||||
@@ -526,6 +675,16 @@ function renderResult() {
|
|||||||
"half-power equivalent diameter",
|
"half-power equivalent diameter",
|
||||||
band.color,
|
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() {
|
function validationWarnings() {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings;
|
if (!state.scene || !state.experiment || !state.arrayDefinition) return warnings;
|
||||||
const zMismatch = state.scene.sources.some(
|
const zMismatch = state.scene.sources.some((source) =>
|
||||||
(source) => Math.abs(source.position[2] - state.experiment.scan.z) > 1e-6,
|
[source.position, ...(source.trajectory ?? []).map(({ position }) => position)].some(
|
||||||
|
(position) => Math.abs(position[2] - state.experiment.scan.z) > 1e-6,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (zMismatch) {
|
if (zMismatch) {
|
||||||
warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다.");
|
warnings.push("일부 source z가 scan z와 달라 ground truth에는 평면 투영값을 사용합니다.");
|
||||||
@@ -669,6 +830,7 @@ function applyValidation(validation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
renderLiveControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleValidation() {
|
function scheduleValidation() {
|
||||||
@@ -680,8 +842,13 @@ function scheduleValidation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourcesForBand(scene, band) {
|
function sourcesForBand(scene, band, time = null) {
|
||||||
return scene.sources.filter((source) => {
|
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;
|
if (source.signal.type !== "tone") return true;
|
||||||
return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
|
return source.signal.frequency >= band.minHz && source.signal.frequency <= band.maxHz;
|
||||||
});
|
});
|
||||||
@@ -705,7 +872,9 @@ function progressMessage(message) {
|
|||||||
return `계산 중 · ${message.stage ?? "worker"}`;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
|
const worker = new Worker(new URL("./simulation-worker.js", import.meta.url), { type: "module" });
|
||||||
activity.worker = worker;
|
activity.worker = worker;
|
||||||
@@ -716,12 +885,16 @@ function runWorker(activity, payload, transfer) {
|
|||||||
if (message.type === "progress") {
|
if (message.type === "progress") {
|
||||||
document.documentElement.dataset.runStage = message.stage ?? "worker";
|
document.documentElement.dataset.runStage = message.stage ?? "worker";
|
||||||
setRunStatus(progressMessage(message));
|
setRunStatus(progressMessage(message));
|
||||||
|
if (activity.kind === "live") elements.liveStatus.textContent = progressMessage(message);
|
||||||
} else if (message.type === "fallback") {
|
} else if (message.type === "fallback") {
|
||||||
activity.fallback = { code: message.code, message: message.message };
|
activity.fallback = { code: message.code, message: message.message };
|
||||||
setRunStatus(`WebGPU 사용 불가 · CPU로 계속합니다 (${message.code})`);
|
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;
|
activity.finished = true;
|
||||||
resolve(message.run);
|
resolve(message[resultKey]);
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
activity.finished = true;
|
activity.finished = true;
|
||||||
const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다.");
|
const error = new Error(message.error?.message ?? "Worker 실행에 실패했습니다.");
|
||||||
@@ -765,6 +938,7 @@ async function runSimulation() {
|
|||||||
state.resultDirty = false;
|
state.resultDirty = false;
|
||||||
syncUnsavedState();
|
syncUnsavedState();
|
||||||
}
|
}
|
||||||
|
resetLiveTimeline();
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
arrayDefinition: structuredClone(state.arrayDefinition),
|
arrayDefinition: structuredClone(state.arrayDefinition),
|
||||||
scene: structuredClone(state.scene),
|
scene: structuredClone(state.scene),
|
||||||
@@ -772,6 +946,7 @@ async function runSimulation() {
|
|||||||
};
|
};
|
||||||
const activity = {
|
const activity = {
|
||||||
jobId: `camera-${Date.now()}-${++jobSequence}`,
|
jobId: `camera-${Date.now()}-${++jobSequence}`,
|
||||||
|
kind: "snapshot",
|
||||||
revision: state.revision,
|
revision: state.revision,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
worker: null,
|
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() {
|
function cancelSimulation() {
|
||||||
const activity = state.active;
|
const activity = state.active;
|
||||||
if (!activity || activity.canceled) return;
|
if (!activity || activity.canceled) return;
|
||||||
@@ -1127,6 +1556,22 @@ function installBandListeners() {
|
|||||||
function installActionListeners() {
|
function installActionListeners() {
|
||||||
elements.runButton.addEventListener("click", runSimulation);
|
elements.runButton.addEventListener("click", runSimulation);
|
||||||
elements.cancelButton.addEventListener("click", cancelSimulation);
|
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.sceneInput.addEventListener("change", () => importDocument("scene", elements.sceneInput));
|
||||||
elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput));
|
elements.arrayInput.addEventListener("change", () => importDocument("array", elements.arrayInput));
|
||||||
elements.experimentInput.addEventListener(
|
elements.experimentInput.addEventListener(
|
||||||
@@ -1229,6 +1674,7 @@ function installActionListeners() {
|
|||||||
event.returnValue = "";
|
event.returnValue = "";
|
||||||
});
|
});
|
||||||
window.addEventListener("unload", () => {
|
window.addEventListener("unload", () => {
|
||||||
|
stopLivePlayback(false);
|
||||||
cancelSimulation();
|
cancelSimulation();
|
||||||
sceneVisualization.destroy();
|
sceneVisualization.destroy();
|
||||||
arrayVisualization.destroy();
|
arrayVisualization.destroy();
|
||||||
@@ -1242,6 +1688,7 @@ async function initialize() {
|
|||||||
installActionListeners();
|
installActionListeners();
|
||||||
setEditableControlsDisabled(true);
|
setEditableControlsDisabled(true);
|
||||||
document.documentElement.dataset.runState = "idle";
|
document.documentElement.dataset.runState = "idle";
|
||||||
|
renderLiveControls();
|
||||||
try {
|
try {
|
||||||
const [arrayDefinition, scene, experiment] = await Promise.all([
|
const [arrayDefinition, scene, experiment] = await Promise.all([
|
||||||
loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition),
|
loadJson(DEFAULT_DOCUMENT_URLS.arrayDefinition),
|
||||||
@@ -1261,6 +1708,7 @@ async function initialize() {
|
|||||||
renderResult();
|
renderResult();
|
||||||
renderHistory();
|
renderHistory();
|
||||||
renderSceneVisualization();
|
renderSceneVisualization();
|
||||||
|
renderLiveControls();
|
||||||
syncUnsavedState();
|
syncUnsavedState();
|
||||||
await validateCurrent();
|
await validateCurrent();
|
||||||
setEditableControlsDisabled(false);
|
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, .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); }
|
.range-field input { width: 110px; accent-color: var(--mint); }
|
||||||
.check-field input { width: 17px; height: 17px; 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%); }
|
.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; }
|
.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; }
|
.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); }
|
.header-actions .button, .workspace-tools .button { flex: 1 1 calc(50% - 5px); }
|
||||||
.scene-workspace { padding: 18px; }
|
.scene-workspace { padding: 18px; }
|
||||||
.workspace-heading { flex-direction: column; }
|
.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-card { min-height: 380px; }
|
||||||
#scene-canvas { height: 420px; }
|
#scene-canvas { height: 420px; }
|
||||||
.history-card { grid-template-columns: 1fr 1fr; }
|
.history-card { grid-template-columns: 1fr 1fr; }
|
||||||
.history-card strong { grid-column: 1 / -1; }
|
.history-card strong { grid-column: 1 / -1; }
|
||||||
}
|
}
|
||||||
@media (max-width: 430px) {
|
@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; }
|
.band-editor__remove { min-height: 34px; }
|
||||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
.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.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,
|
- `single-tone.experiment.json`: rectangular 192채널 배열, 900–1100 Hz band, 50×50 scan,
|
||||||
FFT 512 / hop 256, Auto backend를 쓰는 Experiment
|
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가
|
두 문서는 계약 버전 `1.0.0`이며 기본 Camera 실행, CPU golden test, CPU/WebGPU 비교와 benchmark가
|
||||||
같은 조건을 공유하도록 고정되어 있습니다. fixture를 바꿀 때는 <http://127.0.0.1:4173/tests/>의
|
같은 조건을 공유하도록 고정되어 있습니다. 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();
|
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();
|
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 () => {
|
test("192채널 기준 배열이 계약을 통과한다", async () => {
|
||||||
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
const arrayDefinition = await loadJson("/assets/array-geometries/rectangular-16x12.array.json");
|
||||||
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
|
assertEqual(arrayDefinition.microphones.length, 192, "microphone count");
|
||||||
|
|||||||
Reference in New Issue
Block a user