diff --git a/apps/acoustic-camera/index.html b/apps/acoustic-camera/index.html new file mode 100644 index 0000000..4150469 --- /dev/null +++ b/apps/acoustic-camera/index.html @@ -0,0 +1,32 @@ + + + + + + + Acoustic Camera · micarrsim + + + +
+
+ + micarrsim + Acoustic Camera + + 런타임 확인 중… +
+
+
+

Beamforming laboratory

+

배열 성능 비교하기

+

+ 이 화면에서 scene과 192채널 배열을 불러와 자유음장 전달 신호를 만들고 주파수 대역별 + DAS acoustic image를 ground truth와 비교하게 됩니다. +

+
+
+
+ + + diff --git a/apps/dataset-builder/index.html b/apps/dataset-builder/index.html new file mode 100644 index 0000000..3bc582d --- /dev/null +++ b/apps/dataset-builder/index.html @@ -0,0 +1,32 @@ + + + + + + + Dataset Builder · micarrsim + + + +
+
+ + micarrsim + Dataset Builder + + 런타임 확인 중… +
+
+
+

Scene authoring

+

음향 장면 만들기

+

+ 이 화면에서 기기 이미지에 음원을 배치하고 ambient audio를 혼합해 scene manifest와 + ground truth를 만들게 됩니다. 공통 데이터 계약을 확정한 뒤 편집기를 연결합니다. +

+
+
+
+ + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..84bc302 --- /dev/null +++ b/index.html @@ -0,0 +1,56 @@ + + + + + + + micarrsim + + + +
+
+

192-channel acoustic camera laboratory

+

micarrsim

+

+ 합성 음향 장면을 만들고, 마이크 배열의 자유음장 응답과 DAS 음향 영상을 같은 조건에서 + 비교합니다. +

+
+ 그래픽 장치 확인 중… + 로컬 처리 · 외부 업로드 없음 +
+
+ +
+ + 01 +
+

Dataset Builder

+

기기 이미지에 음원을 배치하고 ambient를 혼합해 재현 가능한 장면을 만듭니다.

+
+ 장면 만들기 → +
+ + + 02 +
+

Acoustic Camera

+

배열 응답을 계산하고 주파수 대역별 DAS 결과를 ground truth와 비교합니다.

+
+ 시뮬레이션 열기 → +
+
+ + +
+ + + diff --git a/packages/ui/app-shell.css b/packages/ui/app-shell.css new file mode 100644 index 0000000..35dbdac --- /dev/null +++ b/packages/ui/app-shell.css @@ -0,0 +1,282 @@ +:root { + color-scheme: dark; + font-family: + Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-synthesis: none; + --bg: #08110f; + --surface: #101c19; + --surface-raised: #172723; + --line: #29423b; + --text: #eef8f4; + --muted: #91aaa2; + --mint: #65f2bf; + --cyan: #58c7e8; + --amber: #ffc56e; + --danger: #ff7b73; + --shadow: 0 24px 80px rgb(0 0 0 / 28%); +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + color: var(--text); + background: + radial-gradient(circle at 12% 0%, rgb(50 132 105 / 18%), transparent 34rem), + radial-gradient(circle at 100% 100%, rgb(37 111 139 / 14%), transparent 38rem), var(--bg); +} + +button, +input, +select, +textarea { + font: inherit; +} + +a { + color: inherit; +} + +.landing-shell { + width: min(1120px, calc(100% - 40px)); + margin: 0 auto; + padding: clamp(48px, 8vw, 112px) 0 40px; +} + +.hero-card { + max-width: 820px; +} + +.eyebrow { + margin: 0 0 18px; + color: var(--mint); + font-size: 0.76rem; + font-weight: 750; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +h1, +h2, +p { + margin-top: 0; +} + +.hero-card h1 { + margin-bottom: 20px; + font-size: clamp(3.4rem, 10vw, 7.8rem); + font-weight: 750; + letter-spacing: -0.075em; + line-height: 0.9; +} + +.hero-copy { + max-width: 760px; + margin-bottom: 28px; + color: #c8d9d3; + font-size: clamp(1.06rem, 2vw, 1.34rem); + line-height: 1.65; +} + +.status-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.status-chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 6px 12px; + border: 1px solid rgb(101 242 191 / 35%); + border-radius: 999px; + color: var(--mint); + background: rgb(101 242 191 / 8%); + font-size: 0.78rem; + font-weight: 650; +} + +.status-chip--neutral { + border-color: var(--line); + color: var(--muted); + background: rgb(255 255 255 / 2%); +} + +.status-chip--warning { + border-color: rgb(255 197 110 / 42%); + color: var(--amber); + background: rgb(255 197 110 / 8%); +} + +.launcher-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: clamp(56px, 9vw, 104px); +} + +.launcher-card { + display: grid; + min-height: 310px; + padding: clamp(24px, 4vw, 38px); + border: 1px solid var(--line); + border-radius: 24px; + color: inherit; + background: linear-gradient(145deg, rgb(255 255 255 / 5%), rgb(255 255 255 / 1%)); + box-shadow: var(--shadow); + text-decoration: none; + transition: + border-color 160ms ease, + transform 160ms ease, + background 160ms ease; +} + +.launcher-card:hover, +.launcher-card:focus-visible { + border-color: var(--mint); + background: linear-gradient(145deg, rgb(101 242 191 / 10%), rgb(255 255 255 / 2%)); + outline: none; + transform: translateY(-4px); +} + +.launcher-card--accent:hover, +.launcher-card--accent:focus-visible { + border-color: var(--cyan); + background: linear-gradient(145deg, rgb(88 199 232 / 11%), rgb(255 255 255 / 2%)); +} + +.launcher-card__number { + color: var(--muted); + font-size: 0.78rem; + font-weight: 750; + letter-spacing: 0.12em; +} + +.launcher-card h2 { + margin-bottom: 14px; + font-size: clamp(1.65rem, 3vw, 2.35rem); + letter-spacing: -0.04em; +} + +.launcher-card p { + margin-bottom: 0; + color: var(--muted); + line-height: 1.7; +} + +.launcher-card__action { + align-self: end; + color: var(--mint); + font-size: 0.9rem; + font-weight: 720; +} + +.launcher-card--accent .launcher-card__action { + color: var(--cyan); +} + +.landing-footer { + display: flex; + gap: 10px; + justify-content: center; + margin-top: 44px; + color: var(--muted); + font-size: 0.78rem; +} + +.landing-footer a { + text-decoration: none; +} + +.landing-footer a:hover { + color: var(--text); +} + +.app-shell { + min-height: 100vh; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + padding: 12px clamp(18px, 3vw, 36px); + border-bottom: 1px solid var(--line); + background: rgb(8 17 15 / 90%); + backdrop-filter: blur(18px); +} + +.app-brand { + display: flex; + align-items: baseline; + gap: 14px; + color: inherit; + text-decoration: none; +} + +.app-brand strong { + font-size: 1.05rem; + letter-spacing: -0.03em; +} + +.app-brand span { + color: var(--muted); + font-size: 0.76rem; +} + +.app-main { + display: grid; + place-items: center; + min-height: calc(100vh - 65px); + padding: 28px; +} + +.empty-state { + width: min(680px, 100%); + padding: clamp(28px, 6vw, 56px); + border: 1px solid var(--line); + border-radius: 24px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.empty-state h1 { + margin-bottom: 18px; + font-size: clamp(2rem, 6vw, 3.5rem); + letter-spacing: -0.055em; +} + +.empty-state p { + color: var(--muted); + line-height: 1.75; +} + +@media (max-width: 720px) { + .launcher-grid { + grid-template-columns: 1fr; + } + + .launcher-card { + min-height: 260px; + } + + .app-brand span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/packages/ui/landing.js b/packages/ui/landing.js new file mode 100644 index 0000000..105710f --- /dev/null +++ b/packages/ui/landing.js @@ -0,0 +1,12 @@ +const statusElement = document.querySelector("#webgpu-status"); + +if (!(statusElement instanceof HTMLElement)) { + throw new Error("WebGPU status element is missing."); +} + +if ("gpu" in navigator) { + statusElement.textContent = "WebGPU 사용 가능"; +} else { + statusElement.textContent = "CPU fallback 사용 예정"; + statusElement.classList.add("status-chip--warning"); +} diff --git a/packages/ui/runtime-status.js b/packages/ui/runtime-status.js new file mode 100644 index 0000000..f1d1a25 --- /dev/null +++ b/packages/ui/runtime-status.js @@ -0,0 +1,8 @@ +const statusElements = document.querySelectorAll("[data-runtime-status]"); +const hasWebGpu = "gpu" in navigator; + +for (const statusElement of statusElements) { + statusElement.textContent = hasWebGpu ? "WebGPU 준비됨" : "CPU fallback"; + statusElement.classList.toggle("status-chip--warning", !hasWebGpu); + statusElement.classList.toggle("status-chip--neutral", hasWebGpu); +} diff --git a/scripts/serve.ps1 b/scripts/serve.ps1 new file mode 100644 index 0000000..d9d5b13 --- /dev/null +++ b/scripts/serve.ps1 @@ -0,0 +1,112 @@ +param( + [ValidateRange(1024, 65535)] + [int]$Port = 4173, + [switch]$NoBrowser +) + +$ErrorActionPreference = 'Stop' +$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$rootWithSeparator = $projectRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar +$serverUrl = "http://127.0.0.1:$Port/" +$listener = [System.Net.HttpListener]::new() +$listener.Prefixes.Add($serverUrl) + +$mimeTypes = @{ + '.css' = 'text/css; charset=utf-8' + '.flac' = 'audio/flac' + '.html' = 'text/html; charset=utf-8' + '.ico' = 'image/x-icon' + '.jpeg' = 'image/jpeg' + '.jpg' = 'image/jpeg' + '.js' = 'text/javascript; charset=utf-8' + '.json' = 'application/json; charset=utf-8' + '.md' = 'text/markdown; charset=utf-8' + '.png' = 'image/png' + '.svg' = 'image/svg+xml' + '.wav' = 'audio/wav' + '.webp' = 'image/webp' +} + +function Send-TextResponse { + param( + [System.Net.HttpListenerResponse]$Response, + [int]$StatusCode, + [string]$Message + ) + + $body = [System.Text.Encoding]::UTF8.GetBytes($Message) + $Response.StatusCode = $StatusCode + $Response.ContentType = 'text/plain; charset=utf-8' + $Response.ContentLength64 = $body.Length + $Response.OutputStream.Write($body, 0, $body.Length) +} + +try { + $listener.Start() + Write-Host "micarrsim is running at $serverUrl" -ForegroundColor Green + Write-Host 'Press Ctrl+C to stop.' -ForegroundColor DarkGray + + if (-not $NoBrowser) { + Start-Process $serverUrl + } + + while ($listener.IsListening) { + $context = $listener.GetContext() + + try { + $relativePath = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + $relativePath = $relativePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar) + if ([string]::IsNullOrWhiteSpace($relativePath)) { + $relativePath = 'index.html' + } + + $candidatePath = [System.IO.Path]::GetFullPath((Join-Path $projectRoot $relativePath)) + $isWithinRoot = $candidatePath.Equals($projectRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $candidatePath.StartsWith($rootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase) + + if (-not $isWithinRoot) { + Send-TextResponse -Response $context.Response -StatusCode 403 -Message 'Forbidden' + continue + } + + if ([System.IO.Directory]::Exists($candidatePath)) { + $candidatePath = Join-Path $candidatePath 'index.html' + } + + if (-not [System.IO.File]::Exists($candidatePath)) { + Send-TextResponse -Response $context.Response -StatusCode 404 -Message 'Not found' + continue + } + + $content = [System.IO.File]::ReadAllBytes($candidatePath) + $extension = [System.IO.Path]::GetExtension($candidatePath).ToLowerInvariant() + $contentType = $mimeTypes[$extension] + if ([string]::IsNullOrWhiteSpace($contentType)) { + $contentType = 'application/octet-stream' + } + + $context.Response.StatusCode = 200 + $context.Response.ContentType = $contentType + $context.Response.ContentLength64 = $content.Length + $context.Response.Headers['Cache-Control'] = 'no-store' + if ($context.Request.HttpMethod -ne 'HEAD') { + $context.Response.OutputStream.Write($content, 0, $content.Length) + } + } + catch { + if ($context.Response.OutputStream.CanWrite) { + Send-TextResponse -Response $context.Response -StatusCode 500 -Message 'Internal server error' + } + Write-Warning $_ + } + finally { + $context.Response.OutputStream.Close() + } + } +} +finally { + if ($listener.IsListening) { + $listener.Stop() + } + $listener.Close() +} diff --git a/start.cmd b/start.cmd new file mode 100644 index 0000000..cb15566 --- /dev/null +++ b/start.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +cd /d "%~dp0" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\serve.ps1" +if errorlevel 1 pause