Skip to content

Commit a4cb107

Browse files
committed
Implement Audio SFX JSON import export behavior - PR_26144_009-audio-sfx-json-import-export-behavior
1 parent da8bfdf commit a4cb107

3 files changed

Lines changed: 159 additions & 16 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Audio / SFX Playground V2 JSON Import Export Validation
2+
3+
PR: `PR_26144_009-audio-sfx-json-import-export-behavior`
4+
5+
## Scope
6+
7+
- Updated only `tools/audio-sfx-playground-v2` plus required reports.
8+
- Made Export JSON perform a browser JSON file download.
9+
- Made Copy JSON copy the same validated export payload used by Export JSON.
10+
- Kept the export payload as the Audio / SFX toolState object containing:
11+
- current active sound payload
12+
- active sound id
13+
- all saved SFX tile entries
14+
- Kept Import JSON validation before any editor/tile mutation.
15+
- Rejected invalid or inconsistent active-sound payloads with a visible Status error.
16+
- Did not add silent fallback or partial render behavior.
17+
18+
## Static Validation
19+
20+
PASS:
21+
22+
- HTML/CSS static validation:
23+
- no `<style>` blocks
24+
- no inline event handlers
25+
- no script tags without `src`
26+
- linked stylesheets resolve
27+
- CSS braces are balanced
28+
- JSON action labels are present
29+
- Audio / SFX Playground V2 JavaScript syntax check:
30+
- `node --check` over `tools/audio-sfx-playground-v2/js/**/*.js`
31+
- JSON import/export behavior check:
32+
- valid exported toolState round-trips through `readToolState`
33+
- invalid active-sound mismatch is rejected
34+
- Export JSON download path creates a JSON blob, click target, and object URL cleanup
35+
- Whitespace validation:
36+
- `git diff --check -- tools/audio-sfx-playground-v2`
37+
- JSON file validation:
38+
- No JSON files changed in this PR.
39+
40+
## Playwright Impact
41+
42+
Playwright impacted: Yes.
43+
44+
Expected validation:
45+
46+
- Workspace V2 launches Audio / SFX Playground V2 with no console errors.
47+
- Export JSON downloads a file containing the active SFX and all saved SFX tiles.
48+
- Copy JSON copies the same validated payload as Export JSON.
49+
- Import JSON restores a valid exported payload, including active SFX and saved tiles.
50+
- Invalid JSON is rejected with a visible Status error.
51+
- Invalid JSON does not mutate the current editor, active SFX, or saved tile list.
52+
53+
Local command results:
54+
55+
- `npm run test:workspace-v2`
56+
- FAIL: PowerShell blocked `npm.ps1` because script execution is disabled on this system.
57+
- `npm.cmd run test:workspace-v2`
58+
- FAIL: package script started, but `playwright` is not installed or not available on PATH.
59+
60+
Because Playwright is unavailable in this local environment, browser launch validation and V8 coverage could not be completed here.
61+
62+
## Coverage
63+
64+
WARN: Runtime JavaScript changed, but Playwright V8 coverage could not be generated because the local Playwright command is unavailable.
65+
66+
- `(WARN) tools/audio-sfx-playground-v2/js/AudioSfxPlaygroundV2App.js - Playwright unavailable`
67+
- `(WARN) tools/audio-sfx-playground-v2/js/services/ToolStateSerializer.js - Playwright unavailable`
68+
69+
## Full Samples Smoke Test
70+
71+
Skipped. This PR only impacts Audio / SFX Playground V2 JSON controls.

tools/audio-sfx-playground-v2/js/AudioSfxPlaygroundV2App.js

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ function nextSoundNumberAfter(soundEntries) {
2323
return highest + 1;
2424
}
2525

26+
function exportFileName(name) {
27+
const baseName = name
28+
.toLowerCase()
29+
.replace(/[^a-z0-9]+/g, "-")
30+
.replace(/^-+|-+$/g, "");
31+
return `${baseName || "audio-sfx"}-tool-state.json`;
32+
}
33+
2634
export class AudioSfxPlaygroundV2App {
2735
constructor({ accordions, actionNav, audioEngine, controls, inspector, preview, serializer, shell, statusLog, tileList, windowRef = window }) {
2836
this.accordions = accordions;
@@ -180,15 +188,39 @@ export class AudioSfxPlaygroundV2App {
180188
this.statusLog.write(`Deleted ${entry.sound.name}.`);
181189
}
182190

183-
exportJson() {
191+
exportPayload() {
184192
const { toolState, validation } = this.currentToolState();
185193
if (!validation.valid) {
186-
this.statusLog.error(validation.message);
194+
return { valid: false, message: validation.message, toolState: null, json: "" };
195+
}
196+
197+
const exportValidation = this.serializer.readToolState(toolState);
198+
if (!exportValidation.valid) {
199+
return { valid: false, message: exportValidation.message, toolState: null, json: "" };
200+
}
201+
return {
202+
valid: true,
203+
message: "",
204+
toolState,
205+
json: JSON.stringify(toolState, null, 2)
206+
};
207+
}
208+
209+
exportJson() {
210+
const exportResult = this.exportPayload();
211+
if (!exportResult.valid) {
212+
this.statusLog.error(`Export JSON failed: ${exportResult.message}`);
187213
this.refreshPreview();
188214
return;
189215
}
190-
this.inspector.showObject(toolState);
191-
this.statusLog.write("JSON preview written to Output Summary.");
216+
217+
try {
218+
this.downloadJson(exportResult);
219+
this.inspector.showObject(exportResult.toolState);
220+
this.statusLog.write(`Exported JSON for ${exportResult.toolState.payload.name}.`);
221+
} catch (error) {
222+
this.statusLog.error(`Export JSON failed: ${error.message}`);
223+
}
192224
}
193225

194226
async importJson(file) {
@@ -200,12 +232,13 @@ export class AudioSfxPlaygroundV2App {
200232
this.statusLog.error(`Import JSON failed: ${result.message}`);
201233
return;
202234
}
203-
this.activeSoundId = result.value.activeSoundId;
204-
this.soundEntries = result.value.soundEntries.map((entry) => ({
235+
const nextSoundEntries = result.value.soundEntries.map((entry) => ({
205236
id: entry.id,
206237
sound: cloneSound(entry.sound)
207238
}));
208-
this.nextSoundNumber = nextSoundNumberAfter(this.soundEntries);
239+
this.activeSoundId = result.value.activeSoundId;
240+
this.soundEntries = nextSoundEntries;
241+
this.nextSoundNumber = nextSoundNumberAfter(nextSoundEntries);
209242
this.controls.loadSound(result.value.sound);
210243
this.renderSoundList();
211244
this.refreshPreview();
@@ -216,25 +249,46 @@ export class AudioSfxPlaygroundV2App {
216249
}
217250

218251
async copyJson() {
219-
const { toolState, validation } = this.currentToolState();
220-
if (!validation.valid) {
221-
this.statusLog.error(validation.message);
252+
const exportResult = this.exportPayload();
253+
if (!exportResult.valid) {
254+
this.statusLog.error(`Copy JSON failed: ${exportResult.message}`);
222255
this.refreshPreview();
223256
return;
224257
}
225258

226-
const json = JSON.stringify(toolState, null, 2);
227-
this.inspector.showObject(toolState);
259+
this.inspector.showObject(exportResult.toolState);
228260
if (typeof this.window.navigator?.clipboard?.writeText !== "function") {
229-
this.statusLog.write("toolState JSON preview written to Output Summary. Clipboard API is unavailable.");
261+
this.statusLog.error("Copy JSON failed: Clipboard API is unavailable.");
230262
return;
231263
}
232264

233265
try {
234-
await this.window.navigator.clipboard.writeText(json);
235-
this.statusLog.write("toolState JSON copied.");
266+
await this.window.navigator.clipboard.writeText(exportResult.json);
267+
this.statusLog.write("JSON copied.");
236268
} catch (error) {
237269
this.statusLog.error(`Copy JSON failed: ${error.message}`);
238270
}
239271
}
272+
273+
downloadJson(exportResult) {
274+
const documentRef = this.window.document;
275+
const BlobConstructor = this.window.Blob;
276+
const urlApi = this.window.URL;
277+
if (!documentRef?.body || typeof BlobConstructor !== "function" || typeof urlApi?.createObjectURL !== "function") {
278+
throw new Error("Browser download APIs are unavailable.");
279+
}
280+
281+
const blob = new BlobConstructor([exportResult.json], { type: "application/json" });
282+
const url = urlApi.createObjectURL(blob);
283+
const link = documentRef.createElement("a");
284+
link.href = url;
285+
link.download = exportFileName(exportResult.toolState.payload.name);
286+
try {
287+
documentRef.body.append(link);
288+
link.click();
289+
} finally {
290+
link.remove();
291+
urlApi.revokeObjectURL(url);
292+
}
293+
}
240294
}

tools/audio-sfx-playground-v2/js/services/ToolStateSerializer.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ function serializeSound(sound) {
2020
};
2121
}
2222

23+
function soundsMatch(first, second) {
24+
return first.attackMs === second.attackMs
25+
&& first.durationMs === second.durationMs
26+
&& first.frequencyHz === second.frequencyHz
27+
&& first.name === second.name
28+
&& first.noise === second.noise
29+
&& first.pitchSweepCents === second.pitchSweepCents
30+
&& first.releaseMs === second.releaseMs
31+
&& first.volume === second.volume
32+
&& first.waveform === second.waveform;
33+
}
34+
2335
function readNumber(value, label, min, max) {
2436
if (!Number.isFinite(value) || value < min || value > max) {
2537
return { ok: false, message: `${label} must be a number between ${min} and ${max}.` };
@@ -151,9 +163,15 @@ export class ToolStateSerializer {
151163
const activeSoundId = typeof value.payload.activeSoundId === "string"
152164
? value.payload.activeSoundId
153165
: "";
154-
if (activeSoundId && !soundEntries.value.some((entry) => entry.id === activeSoundId)) {
166+
const activeEntry = activeSoundId
167+
? soundEntries.value.find((entry) => entry.id === activeSoundId)
168+
: null;
169+
if (activeSoundId && !activeEntry) {
155170
return { valid: false, message: `Active sound id is missing from payload.sounds: ${activeSoundId}.` };
156171
}
172+
if (activeEntry && !soundsMatch(sound.value, activeEntry.sound)) {
173+
return { valid: false, message: `Active sound payload does not match payload.sounds entry: ${activeSoundId}.` };
174+
}
157175
return {
158176
valid: true,
159177
value: {

0 commit comments

Comments
 (0)