feat: streamline arbitrary audio sources
This commit is contained in:
@@ -12,14 +12,19 @@
|
|||||||
## 기본 사용법
|
## 기본 사용법
|
||||||
|
|
||||||
1. **배경 이미지**를 선택하거나 이미지 없이 grid를 사용합니다.
|
1. **배경 이미지**를 선택하거나 이미지 없이 grid를 사용합니다.
|
||||||
2. **오디오 추가**로 source 또는 ambient에 사용할 파일을 연결합니다.
|
2. 녹음 파일을 음원으로 쓸 때는 **+ 오디오 파일 음원**을 누르고 파일을 선택합니다. 파일 asset과
|
||||||
3. **+ 음원 추가**를 누르고 카드를 선택한 뒤 canvas를 클릭하거나 x/y/z를 입력합니다.
|
이를 사용하는 source가 함께 생성되고 새 source가 자동으로 선택됩니다.
|
||||||
4. source 종류를 설정합니다.
|
3. 합성 신호가 필요하면 **+ 합성 음원**을 누릅니다.
|
||||||
|
4. 선택된 카드를 확인한 뒤 canvas를 클릭하거나 x/y/z를 입력해 위치를 지정합니다.
|
||||||
|
5. 필요하면 source 종류를 변경하거나 세부 값을 설정합니다.
|
||||||
- `Tone`: 주파수와 위상을 갖는 합성 정현파
|
- `Tone`: 주파수와 위상을 갖는 합성 정현파
|
||||||
- `Noise`: seed로 재현되는 white 또는 pink noise
|
- `Noise`: seed로 재현되는 white 또는 pink noise
|
||||||
- `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능
|
- `Audio asset`: 선택한 파일의 channel을 사용하며 짧은 파일은 반복 가능
|
||||||
5. 필요하면 ambient 종류와 gain을 설정합니다.
|
6. 필요하면 ambient 종류와 gain을 설정합니다.
|
||||||
6. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
|
7. 검증 카드가 **저장 가능한 장면**이면 **Scene JSON 저장**을 누릅니다.
|
||||||
|
|
||||||
|
오디오 파일은 PCM WAV가 가장 안정적입니다. MP3, FLAC, OGG, M4A 등의 실제 decode 지원 여부는
|
||||||
|
브라우저와 운영체제 codec에 따라 달라지며, 지원하지 않는 파일은 미리 듣기 시 오류로 표시됩니다.
|
||||||
|
|
||||||
길이, sample rate, 음속, seed와 mix 정규화도 Scene JSON에 저장됩니다. 이미지가 있으면 표시
|
길이, sample rate, 음속, seed와 mix 정규화도 Scene JSON에 저장됩니다. 이미지가 있으면 표시
|
||||||
평면의 x/y 범위, z와 image pixel 크기가 ground truth 좌표 변환에 사용됩니다.
|
평면의 x/y 범위, z와 image pixel 크기가 ground truth 좌표 변환에 사용됩니다.
|
||||||
@@ -40,7 +45,8 @@ id, 종류, 파일명과 SHA-256이 들어갑니다.
|
|||||||
|
|
||||||
- 파일 한 개는 최대 128 MiB, 연결 파일 합계는 최대 512 MiB입니다.
|
- 파일 한 개는 최대 128 MiB, 연결 파일 합계는 최대 512 MiB입니다.
|
||||||
- decode된 오디오의 합계는 최대 192 MiB입니다.
|
- decode된 오디오의 합계는 최대 192 MiB입니다.
|
||||||
- Scene을 다시 불러오면 브라우저 권한이 없으므로 원본 이미지와 오디오를 다시 선택해야 합니다.
|
- Scene을 다시 불러오면 브라우저 권한이 없으므로 **원본 오디오 연결**에서 같은 오디오를 다시
|
||||||
|
선택해야 합니다. 이 동작은 새 source를 만들지 않고 manifest의 SHA-256으로 기존 asset을 연결합니다.
|
||||||
- 같은 이름의 파일도 SHA-256이 manifest와 다르면 기존 asset으로 연결되지 않습니다.
|
- 같은 이름의 파일도 SHA-256이 manifest와 다르면 기존 asset으로 연결되지 않습니다.
|
||||||
- 사용 중인 asset은 먼저 source, ambient 또는 image 참조를 바꾼 뒤 제거할 수 있습니다.
|
- 사용 중인 asset은 먼저 source, ambient 또는 image 참조를 바꾼 뒤 제거할 수 있습니다.
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button class="button button--danger is-hidden" id="remove-image-button" type="button">이미지 해제</button>
|
<button class="button button--danger is-hidden" id="remove-image-button" type="button">이미지 해제</button>
|
||||||
<label class="button button--ghost file-button">
|
<label class="button button--ghost file-button">
|
||||||
오디오 추가
|
원본 오디오 연결
|
||||||
<input id="audio-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
|
<input id="audio-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
|
||||||
</label>
|
</label>
|
||||||
<label class="button button--ghost file-button">
|
<label class="button button--ghost file-button">
|
||||||
@@ -59,16 +59,22 @@
|
|||||||
<div class="source-strip-heading">
|
<div class="source-strip-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>음원 ground truth</h2>
|
<h2>음원 ground truth</h2>
|
||||||
<p>카드를 선택한 뒤 canvas를 클릭해 위치를 지정합니다.</p>
|
<p>파일 음원은 선택 즉시 생성됩니다. 카드를 선택한 뒤 canvas를 클릭해 위치를 지정하세요.</p>
|
||||||
|
</div>
|
||||||
|
<div class="source-actions">
|
||||||
|
<button class="button button--ghost" id="add-source-button" type="button">+ 합성 음원</button>
|
||||||
|
<label class="button button--primary file-button">
|
||||||
|
+ 오디오 파일 음원
|
||||||
|
<input id="audio-source-input" type="file" accept="audio/*,.wav,.flac,.mp3,.ogg,.m4a" multiple />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="button button--primary" id="add-source-button" type="button">+ 음원 추가</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="source-strip" id="source-list" aria-label="음원 목록"></div>
|
<div class="source-strip" id="source-list" aria-label="음원 목록"></div>
|
||||||
|
|
||||||
<section class="asset-shelf">
|
<section class="asset-shelf">
|
||||||
<div>
|
<div>
|
||||||
<h2>로컬 assets</h2>
|
<h2>로컬 assets</h2>
|
||||||
<p>원본 파일은 업로드되지 않습니다. Camera에서 JSON과 함께 같은 파일을 선택하세요.</p>
|
<p>원본은 업로드되지 않고 브라우저에서만 처리됩니다. PCM WAV를 권장하며, 다른 codec 지원은 환경에 따라 다릅니다.<br />Scene을 다시 불러온 뒤에는 위의 <strong>원본 오디오 연결</strong>에서 같은 파일을 선택하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="asset-list" id="asset-list"></div>
|
<div class="asset-list" id="asset-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const byId = (id) => {
|
|||||||
|
|
||||||
const elements = Object.fromEntries(
|
const elements = Object.fromEntries(
|
||||||
[
|
[
|
||||||
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-input", "scene-input",
|
"preview-button", "wav-button", "export-button", "image-input", "remove-image-button", "audio-input", "audio-source-input", "scene-input",
|
||||||
"scene-canvas", "canvas-empty", "canvas-badge", "workspace-title", "source-list", "add-source-button",
|
"scene-canvas", "canvas-empty", "canvas-badge", "workspace-title", "source-list", "add-source-button",
|
||||||
"asset-list", "scene-name", "scene-id", "scene-duration", "sample-rate", "speed-of-sound", "scene-seed",
|
"asset-list", "scene-name", "scene-id", "scene-duration", "sample-rate", "speed-of-sound", "scene-seed",
|
||||||
"mix-normalization", "target-peak", "plane-x-min", "plane-x-max", "plane-y-min", "plane-y-max",
|
"mix-normalization", "target-peak", "plane-x-min", "plane-x-max", "plane-y-min", "plane-y-max",
|
||||||
@@ -83,6 +83,21 @@ function selectedSource() {
|
|||||||
return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null;
|
return state.scene?.sources.find((source) => source.id === state.selectedSourceId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceNameForAudioFile(file, fallback) {
|
||||||
|
const stem = file.name.replace(/\.[^.]+$/, "").trim();
|
||||||
|
return (stem || fallback).slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAssetBackedSource(descriptor, file) {
|
||||||
|
if (state.scene.sources.length >= 32) return null;
|
||||||
|
const source = createSource(state.scene);
|
||||||
|
source.name = sourceNameForAudioFile(file, source.name);
|
||||||
|
changeSignalType(source, "asset", descriptor.id);
|
||||||
|
state.scene.sources.push(source);
|
||||||
|
state.selectedSourceId = source.id;
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
function numberFrom(element) {
|
function numberFrom(element) {
|
||||||
return element.valueAsNumber;
|
return element.valueAsNumber;
|
||||||
}
|
}
|
||||||
@@ -598,6 +613,42 @@ function bindSourceControls() {
|
|||||||
bindInput("source-loop", (input) => { selectedSource().signal.loop = input.checked; }, "change");
|
bindInput("source-loop", (input) => { selectedSource().signal.loop = input.checked; }, "change");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAudioFiles(files, { createSources = false } = {}) {
|
||||||
|
const epoch = state.assetEpoch;
|
||||||
|
let attachedCount = 0;
|
||||||
|
let createdCount = 0;
|
||||||
|
let lastError = null;
|
||||||
|
for (const file of files) {
|
||||||
|
if (epoch !== state.assetEpoch) return;
|
||||||
|
if (createSources && state.scene.sources.length >= 32) {
|
||||||
|
lastError = new Error("음원은 최대 32개까지 추가할 수 있습니다.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const descriptor = await attachFile(file, "audio", epoch);
|
||||||
|
if (!descriptor) return;
|
||||||
|
attachedCount += 1;
|
||||||
|
if (createSources && createAssetBackedSource(descriptor, file)) createdCount += 1;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (epoch !== state.assetEpoch) return;
|
||||||
|
if (attachedCount > 0) {
|
||||||
|
renderAssetList();
|
||||||
|
renderSourceCards();
|
||||||
|
syncSourceEditor();
|
||||||
|
syncAmbientEditor();
|
||||||
|
renderCanvas();
|
||||||
|
const status = createSources
|
||||||
|
? `${createdCount}개 오디오 파일 음원 추가됨`
|
||||||
|
: `${attachedCount}개 원본 오디오 연결됨`;
|
||||||
|
markChanged(lastError ? `${status} · ${lastError.message}` : status);
|
||||||
|
} else if (lastError) {
|
||||||
|
setStatus(lastError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createAmbientSignal(type) {
|
function createAmbientSignal(type) {
|
||||||
if (type === "white" || type === "pink") return { type: "noise", color: type, seedOffset: 0 };
|
if (type === "white" || type === "pink") return { type: "noise", color: type, seedOffset: 0 };
|
||||||
const assetId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
|
const assetId = state.scene.assets.find((asset) => asset.kind === "audio")?.id ?? "";
|
||||||
@@ -704,22 +755,12 @@ function bindFileControls() {
|
|||||||
elements["audio-input"].addEventListener("change", async (event) => {
|
elements["audio-input"].addEventListener("change", async (event) => {
|
||||||
const files = [...event.target.files];
|
const files = [...event.target.files];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
const epoch = state.assetEpoch;
|
if (files.length > 0) await handleAudioFiles(files);
|
||||||
for (const file of files) {
|
});
|
||||||
if (epoch !== state.assetEpoch) return;
|
elements["audio-source-input"].addEventListener("change", async (event) => {
|
||||||
try {
|
const files = [...event.target.files];
|
||||||
await attachFile(file, "audio", epoch);
|
event.target.value = "";
|
||||||
} catch (error) {
|
if (files.length > 0) await handleAudioFiles(files, { createSources: true });
|
||||||
setStatus(error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (epoch !== state.assetEpoch) return;
|
|
||||||
if (files.length > 0) {
|
|
||||||
renderAssetList();
|
|
||||||
syncSourceEditor();
|
|
||||||
syncAmbientEditor();
|
|
||||||
markChanged(`${files.length}개 오디오 처리 완료`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
elements["scene-input"].addEventListener("change", async (event) => {
|
elements["scene-input"].addEventListener("change", async (event) => {
|
||||||
const [file] = event.target.files;
|
const [file] = event.target.files;
|
||||||
|
|||||||
@@ -179,6 +179,14 @@
|
|||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.source-strip-heading h2,
|
.source-strip-heading h2,
|
||||||
.asset-shelf h2 {
|
.asset-shelf h2 {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
@@ -524,10 +532,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-actions,
|
.header-actions,
|
||||||
.workspace-tools {
|
.workspace-tools,
|
||||||
|
.source-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-actions .button {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 calc(50% - 5px);
|
||||||
|
}
|
||||||
|
|
||||||
.scene-workspace {
|
.scene-workspace {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ Unit과 integration test가 같은 페이지에서 실행됩니다. WebGPU 장
|
|||||||
- `webgpu.test.js`: 실제 adapter가 있으면 CPU/GPU map 오차, peak와 강제 bin/point batching;
|
- `webgpu.test.js`: 실제 adapter가 있으면 CPU/GPU map 오차, peak와 강제 bin/point batching;
|
||||||
adapter가 없으면 Auto CPU fallback
|
adapter가 없으면 Auto CPU fallback
|
||||||
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
|
- `server.test.js`: 숨김 저장소 경로 차단, GET/HEAD 제한과 보안 response headers
|
||||||
- `builder-ui.test.js`: 이미지·오디오 연결, canvas 편집, Preview WAV/Scene 다운로드, SHA 재연결과
|
- `builder-ui.test.js`: 파일 선택 한 번으로 오디오 음원 생성, 이미지·오디오 연결, canvas 편집,
|
||||||
잘못된 import 보존
|
Preview WAV/Scene 다운로드, SHA 재연결과 잘못된 import 보존
|
||||||
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat 비교
|
- `camera-ui.test.js`: Dedicated Worker CPU/WebGPU 실행, 취소, Result schema, rectangular/Fermat 비교
|
||||||
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
- `workflow-ui.test.js`: Dataset Builder의 Scene JSON과 원본 assets를 Acoustic Camera에 넘겨
|
||||||
Result JSON을 저장하는 end-to-end 흐름
|
Result JSON을 저장하는 end-to-end 흐름
|
||||||
|
|||||||
@@ -73,20 +73,21 @@ test("Dataset Builder 전체 저장·재연결 흐름이 동일한 scene과 prev
|
|||||||
const staleSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="#ff0000"/></svg>';
|
const staleSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240"><rect width="320" height="240" fill="#ff0000"/></svg>';
|
||||||
const staleImageFile = new windowObject.File([staleSvg], "stale.svg", { type: "image/svg+xml" });
|
const staleImageFile = new windowObject.File([staleSvg], "stale.svg", { type: "image/svg+xml" });
|
||||||
|
|
||||||
dispatchFiles(windowObject, documentObject.querySelector("#audio-input"), [audioFile]);
|
dispatchFiles(windowObject, documentObject.querySelector("#audio-source-input"), [audioFile]);
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => [...documentObject.querySelectorAll(".asset-chip__state")].some((item) => item.textContent === "연결됨"),
|
() => [...documentObject.querySelectorAll(".asset-chip__state")].some((item) => item.textContent === "연결됨"),
|
||||||
"오디오 hash/decode",
|
"오디오 파일 음원 생성",
|
||||||
);
|
);
|
||||||
|
await waitFor(() => documentObject.querySelectorAll(".source-card").length === 2, "asset source card");
|
||||||
|
assertEqual(documentObject.querySelector("#source-type").value, "asset", "import selects asset source");
|
||||||
|
assertEqual(documentObject.querySelector("#source-name").value, "machine", "source name comes from file");
|
||||||
|
assert(documentObject.querySelector("#source-asset").value !== "", "imported asset is selected");
|
||||||
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [staleImageFile]);
|
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [staleImageFile]);
|
||||||
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
|
dispatchFiles(windowObject, documentObject.querySelector("#image-input"), [imageFile]);
|
||||||
await waitFor(() => documentObject.querySelectorAll(".asset-chip__state").length === 2, "이미지 decode");
|
await waitFor(() => documentObject.querySelectorAll(".asset-chip__state").length === 2, "이미지 decode");
|
||||||
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "asset 준비");
|
await waitFor(() => documentObject.documentElement.dataset.assetsReady === "true", "asset 준비");
|
||||||
|
|
||||||
documentObject.querySelector("#add-source-button").click();
|
|
||||||
assertEqual(documentObject.querySelectorAll(".source-card").length, 2, "source card count");
|
assertEqual(documentObject.querySelectorAll(".source-card").length, 2, "source card count");
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#source-name"), "asset source");
|
|
||||||
dispatchValue(windowObject, documentObject.querySelector("#source-type"), "asset", "change");
|
|
||||||
dispatchChecked(windowObject, documentObject.querySelector("#source-loop"), true);
|
dispatchChecked(windowObject, documentObject.querySelector("#source-loop"), true);
|
||||||
|
|
||||||
const canvas = documentObject.querySelector("#scene-canvas");
|
const canvas = documentObject.querySelector("#scene-canvas");
|
||||||
|
|||||||
Reference in New Issue
Block a user