diff --git a/scenes/89,-12-audio-playback-position/.dclignore b/scenes/89,-12-audio-playback-position/.dclignore new file mode 100644 index 00000000..7499ff37 --- /dev/null +++ b/scenes/89,-12-audio-playback-position/.dclignore @@ -0,0 +1,20 @@ +.* +bin/*.map +package-lock.json +yarn-lock.json +build.json +export +tsconfig.json +tslint.json +node_modules +*.ts +*.tsx +.vscode +Dockerfile +dist +README.md +*.blend +*.fbx +*.zip +*.rar +src diff --git a/scenes/89,-12-audio-playback-position/.gitignore b/scenes/89,-12-audio-playback-position/.gitignore new file mode 100644 index 00000000..cccdd100 --- /dev/null +++ b/scenes/89,-12-audio-playback-position/.gitignore @@ -0,0 +1,13 @@ +package-lock.json +*.js +node_modules +bin/ +.DS_Store +**/.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.editor +.idea +.vscode +dclcontext diff --git a/scenes/89,-12-audio-playback-position/audio/tone-30s.mp3 b/scenes/89,-12-audio-playback-position/audio/tone-30s.mp3 new file mode 100644 index 00000000..bfd8ba7a Binary files /dev/null and b/scenes/89,-12-audio-playback-position/audio/tone-30s.mp3 differ diff --git a/scenes/89,-12-audio-playback-position/images/scene-thumbnail.png b/scenes/89,-12-audio-playback-position/images/scene-thumbnail.png new file mode 100644 index 00000000..b3dc5806 Binary files /dev/null and b/scenes/89,-12-audio-playback-position/images/scene-thumbnail.png differ diff --git a/scenes/89,-12-audio-playback-position/main.crdt b/scenes/89,-12-audio-playback-position/main.crdt new file mode 100644 index 00000000..e69de29b diff --git a/scenes/89,-12-audio-playback-position/package.json b/scenes/89,-12-audio-playback-position/package.json new file mode 100644 index 00000000..4b43f2d4 --- /dev/null +++ b/scenes/89,-12-audio-playback-position/package.json @@ -0,0 +1,24 @@ +{ + "name": "audio-playback-position", + "version": "1.0.0", + "description": "Test scene for AudioEvent playback-position reports (tickNumber / currentOffset / clipLength) and audioEventsSystem.registerAudioPlaybackEntity / getAudioPlayback", + "scripts": { + "start": "sdk-commands start", + "deploy": "sdk-commands deploy", + "build": "sdk-commands build" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=6.0.0" + }, + "prettier": { + "semi": false, + "singleQuote": true, + "printWidth": 120, + "trailingComma": "none" + }, + "devDependencies": { + "@dcl/sdk": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/audio-event-playback-position/dcl-sdk-7.29.1-35640312842.commit-7ee164e.tgz", + "@dcl/js-runtime": "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/audio-event-playback-position/@dcl/js-runtime/dcl-js-runtime-7.29.1-35640312842.commit-7ee164e.tgz" + } +} diff --git a/scenes/89,-12-audio-playback-position/scene.json b/scenes/89,-12-audio-playback-position/scene.json new file mode 100644 index 00000000..99e80d08 --- /dev/null +++ b/scenes/89,-12-audio-playback-position/scene.json @@ -0,0 +1,42 @@ +{ + "ecs7": true, + "runtimeVersion": "7", + "display": { + "title": "Audio Playback Position Reports Test", + "description": "Test scene for the AudioEvent playback-position reports (tickNumber, currentOffset, clipLength) written by the renderer while an AudioSource plays. Covers audioEventsSystem.registerAudioPlaybackEntity / removeAudioPlaybackEntity (push), getAudioPlayback (pull), the lag computation against a per-tick scene clock versus the naive processing-time clock, seek landing, loop wrap, and that registerAudioEventsEntity still fires only on media-state changes.", + "navmapThumbnail": "images/scene-thumbnail.png", + "favicon": "favicon_asset" + }, + "owner": "", + "contact": { + "name": "Decentraland", + "email": "developer@decentraland.org" + }, + "main": "bin/index.js", + "tags": ["audio", "test"], + "scene": { + "parcels": ["89,-12"], + "base": "89,-12" + }, + "spawnPoints": [ + { + "name": "spawn1", + "default": true, + "position": { + "x": [0, 3], + "y": [0, 0], + "z": [0, 3] + }, + "cameraTarget": { + "x": 8, + "y": 1, + "z": 8 + } + } + ], + "requiredPermissions": [], + "featureToggles": {}, + "worldConfiguration": { + "name": "sdk7testscenes.dcl.eth" + } +} diff --git a/scenes/89,-12-audio-playback-position/src/index.ts b/scenes/89,-12-audio-playback-position/src/index.ts new file mode 100644 index 00000000..f0c3cec7 --- /dev/null +++ b/scenes/89,-12-audio-playback-position/src/index.ts @@ -0,0 +1,430 @@ +/** + * Audio Playback Position Reports + * + * One idea, made visible: a renderer starts a clip 100-250 ms after being asked to, so a scene that trusts its own + * clock runs early against the sound. Renderers now report the clip position into the AudioEvent component while + * a clip plays (protocol decentraland/protocol#488, SDK decentraland/js-sdk-toolchain#1624, ADR-318 + * decentraland/adr#324, Unity explorer decentraland/unity-explorer#10123), and a scene can correct itself. + * + * PBAudioEvent gained three OPTIONAL fields: + * - tickNumber scene tick (equals EngineInfo.tickNumber) in which the renderer sampled the position + * - currentOffset clip playback position in seconds at that tick + * - clipLength total clip length in seconds, when known + * `state` (MediaState) and `timestamp` (a per-entity monotonic counter, NOT a time) are unchanged. + * + * PBAudioSource gained one OPTIONAL field, and it is the gate for all of the above: + * - reportPlaybackPosition opt in to position reports (default false) + * Media-state changes are reported either way; positions are NOT. A scene that registers a playback callback + * without setting this flag on its AudioSource receives nothing, silently. It is opt-in because a position is + * written whenever the playhead moves - far more often than a state changes - a scene can hold many sources, and + * AudioEvent is a grow-only set capped at 100 entries per entity, so one always-on source fills its own buffer in + * under two seconds. + * + * THE DEMO: three cubes behind the buttons, and a clip that beeps on every whole second. + * CLOCK flashes on each whole second of the scene clock (what the scene assumes the audio is doing) + * AUDIO flashes on each whole second of (clock - lag), lag measured through tickNumber + * NAIVE flashes on each whole second of (clock - naive lag), naive lag measured at processing time + * Press PLAY and listen. AUDIO lines up with the beeps. CLOCK runs early by the start delay. NAIVE is off by + * the report's transport delay: a report says where the clip was at tick N but reaches the scene a few ticks + * later, so comparing it with the clock at processing time is wrong by exactly that gap. That gap is why the + * field is a tick and not a timestamp: the scene clock has to be read at the tick the position was sampled in. + * The SDK keeps that history, so a scene never rebuilds it: + * registerAudioPlaybackEntity -> { report, sceneTime, offset }, sceneTime already at report.tickNumber + * lag = (sceneTime - sceneTimeAtPlay) * 1000 - offset * 1000 + * + * Buttons: PLAY (from 0, clock -> 0), SEEK 10s (currentTime = 10, clock -> 10 s; the next report shows where the + * renderer actually landed), STOP. Corner buttons cover the rest of the API: LOOP toggles AudioSource.loop (the + * offset wraps while the clock keeps counting), PUSH CB unregisters and re-registers the playback callback. + * + * API under test (audioEventsSystem from '@dcl/sdk/ecs'): + * - AudioSource.reportPlaybackPosition: true - REQUIRED. Nothing below delivers a position without it, and + * there is no error to tell you: the callback simply never runs. + * - registerAudioPlaybackEntity / removeAudioPlaybackEntity: the newest position report of the frame, already + * resolved against the scene clock at its own tick. Drives AUDIO; the same reading timed on arrival instead + * drives NAIVE, so one callback shows both. Also behind the PUSH CB toggle. + * - getSceneTimeAtTick(tick): the same per-tick lookup on its own, for raw reports and for video + * - getAudioPlayback(entity): latest report carrying currentOffset, or undefined (polled, shown in the side panel) + * - registerAudioEventsEntity (pre-existing): still fires ONLY on media-state changes (side panel counts both + * callbacks so the difference is visible) + * Renderers that never send a position show "no position reports" and only CLOCK flashes - as does forgetting + * reportPlaybackPosition, which is why the message names both. + * Note the floor on accuracy: currentOffset is the decoder's playhead, and the output path adds tens of + * milliseconds more that no field carries, so AUDIO lands close to the beep rather than exactly on it. + * + * Until the protocol and SDK PRs merge this scene needs the branch build of @dcl/sdk pinned in package.json, + * and only the Unity explorer branch produces position reports. + */ +import { + engine, + Entity, + Transform, + MeshRenderer, + MeshCollider, + TextShape, + Billboard, + Material, + AudioSource, + EngineInfo, + audioEventsSystem, + MediaState, + PBAudioEvent, + AudioPlaybackSample, + pointerEventsSystem, + InputAction +} from '@dcl/sdk/ecs' +import { Color4, Vector3 } from '@dcl/sdk/math' + +// 30 s generated tone: a 120 ms beep on every whole second whose pitch rises 40 Hz per second, over a quiet hum. +const CLIP = 'audio/tone-30s.mp3' +const SEEK_TARGET_SECONDS = 10 +const NO_REPORTS_AFTER_MS = 3000 // playing this long with AudioEvent state but no position => older renderer +const BEAT_FLASH_MS = 120 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function createButton(position: Vector3, color: Color4, scale = 1.2): Entity { + const entity = engine.addEntity() + Transform.create(entity, { position, scale: Vector3.create(scale, scale, scale) }) + MeshRenderer.setBox(entity) + MeshCollider.setBox(entity) + Material.setPbrMaterial(entity, { albedoColor: color }) + return entity +} + +function createLabel(position: Vector3, fontSize = 2, color: Color4 = Color4.White(), text = ''): Entity { + const entity = engine.addEntity() + Transform.create(entity, { position }) + TextShape.create(entity, { text, fontSize, textColor: color, outlineWidth: 0.1, outlineColor: Color4.Black() }) + Billboard.create(entity, {}) + return entity +} + +// Only touches the mutable component when the text changes, so per-frame refreshes do not emit CRDT traffic. +function setLabel(entity: Entity, text: string): void { + if (TextShape.get(entity).text === text) return + TextShape.getMutable(entity).text = text +} + +function mediaStateName(state: MediaState): string { + switch (state) { + case MediaState.MS_NONE: + return 'MS_NONE' + case MediaState.MS_ERROR: + return 'MS_ERROR' + case MediaState.MS_LOADING: + return 'MS_LOADING' + case MediaState.MS_READY: + return 'MS_READY' + case MediaState.MS_PLAYING: + return 'MS_PLAYING' + case MediaState.MS_BUFFERING: + return 'MS_BUFFERING' + case MediaState.MS_SEEKING: + return 'MS_SEEKING' + case MediaState.MS_PAUSED: + return 'MS_PAUSED' + default: + return `UNKNOWN(${state})` + } +} + +const fmtMs = (value: number | undefined) => (value === undefined ? 'n/a' : `${Math.round(value)} ms`) +const fmtSeconds = (value: number | undefined) => (value === undefined ? 'n/a' : `${value.toFixed(2)} s`) + +// --------------------------------------------------------------------------- +// Scene clock: the position the scene EXPECTS, in ms, as if the renderer had reacted instantly to the last +// PLAY/SEEK. Recorded once per tick under EngineInfo.tickNumber so a report sampled at tick N is compared with +// the clock at tick N, not with the clock when the report is processed. +// --------------------------------------------------------------------------- +let playSceneTimeS: number | undefined // SDK scene clock (s) at the last PLAY/SEEK; undefined until pressed +let clockOffsetMs = 0 // 0 after PLAY, 10000 after SEEK +let currentTick = -1 + +// The SDK's own scene clock, in ms, for the tick being processed. The per-tick history behind it lives in +// audioEventsSystem, which is the point of the feature: a scene does not keep one of its own. +function sdkSceneTimeMs(): number | undefined { + const tick = EngineInfo.getOrNull(engine.RootEntity)?.tickNumber + if (tick === undefined) return undefined + currentTick = tick + const seconds = audioEventsSystem.getSceneTimeAtTick(tick) + return seconds === undefined ? undefined : seconds * 1000 +} + +// Where the scene EXPECTS the clip to be, as if the renderer had reacted instantly to the last PLAY/SEEK. +function sceneClockMs(): number | undefined { + const now = sdkSceneTimeMs() + if (now === undefined || playSceneTimeS === undefined) return undefined + return now - playSceneTimeS * 1000 + clockOffsetMs +} + +// --------------------------------------------------------------------------- +// Audio entity and measurements +// --------------------------------------------------------------------------- +const audioEntity = engine.addEntity() +Transform.create(audioEntity, { position: Vector3.create(8, 1, 8) }) +// reportPlaybackPosition is the opt-in: without it the renderer reports media-state changes only, and the +// registerAudioPlaybackEntity callback below never fires. +AudioSource.create(audioEntity, { audioClipUrl: CLIP, playing: false, loop: false, reportPlaybackPosition: true }) + +let lagMs: number | undefined // resolved at the report's own tick: the correct figure +let naiveLagMs: number | undefined // clock at processing time - offset: wrong by the transport delay +let firstLagMs: number | undefined +let lastReportWallMs: number | undefined +let positionReportCount = 0 +let stateCallbackCount = 0 +let pushCallbackRegistered = false +const stateLog: string[] = [] + +// --------------------------------------------------------------------------- +// The demo: three beat cubes, centre stage +// --------------------------------------------------------------------------- +type BeatCube = { entity: Entity; color: Color4; lastSecond: number; flashUntil: number; lit: boolean } + +function createBeatCube(position: Vector3, color: Color4, caption: string): BeatCube { + const entity = engine.addEntity() + Transform.create(entity, { position, scale: Vector3.create(2.2, 2.2, 2.2) }) + MeshRenderer.setBox(entity) + Material.setPbrMaterial(entity, { albedoColor: color }) + createLabel(Vector3.create(position.x, position.y + 1.9, position.z), 2.2, Color4.White(), caption) + return { entity, color, lastSecond: -1, flashUntil: 0, lit: false } +} + +function updateBeat(cube: BeatCube, positionMs: number | undefined, wallNow: number): void { + if (positionMs !== undefined) { + const second = Math.floor(positionMs / 1000) + if (second !== cube.lastSecond) { + cube.lastSecond = second + cube.flashUntil = wallNow + BEAT_FLASH_MS + } + } + const lit = wallNow < cube.flashUntil + if (lit === cube.lit) return + cube.lit = lit + Material.setPbrMaterial(cube.entity, { albedoColor: lit ? Color4.White() : cube.color }) +} + +const clockBeat = createBeatCube( + Vector3.create(4, 2.6, 9), + Color4.create(0.75, 0.6, 0.1, 1), + 'CLOCK\nwhat the scene assumes' +) +const audioBeat = createBeatCube( + Vector3.create(8, 2.6, 9), + Color4.create(0.1, 0.6, 0.7, 1), + 'AUDIO\nclock - lag (via tickNumber)' +) +const naiveBeat = createBeatCube( + Vector3.create(12, 2.6, 9), + Color4.create(0.7, 0.25, 0.25, 1), + 'NAIVE\nclock - lag (at processing time)' +) + +// --------------------------------------------------------------------------- +// Readouts: one headline under the cubes, one compact panel, one side panel for the rest of the API +// --------------------------------------------------------------------------- +const headline = createLabel( + Vector3.create(8, 6.4, 9), + 2.6, + Color4.Yellow(), + 'Press PLAY, then listen: which cube flashes with the beep?' +) +const panel = createLabel(Vector3.create(8, 5.1, 9), 1.6, Color4.create(0.85, 0.95, 1, 1), '') +const sidePanel = createLabel(Vector3.create(14.6, 4.6, 6), 1.3, Color4.create(1, 0.85, 0.5, 1), '') + +function renderPanel(): void { + const clock = sceneClockMs() + if (clock === undefined) { + setLabel(panel, 'scene clock: not started') + return + } + const lines = [`scene clock ${fmtMs(clock)} tick ${currentTick}`] + if (lagMs === undefined) { + const sincePress = clock - clockOffsetMs + lines.push( + sincePress > NO_REPORTS_AFTER_MS && stateCallbackCount > 0 + ? 'no position reports: older renderer, or reportPlaybackPosition not set (only CLOCK flashes)' + : 'waiting for the first position report...' + ) + } else { + lines.push(`audio behind clock by ${fmtMs(lagMs)} (correct: resolved at the report's own tick)`) + lines.push(`naive figure ${fmtMs(naiveLagMs)} (clock at processing time; off by the transport delay)`) + if (firstLagMs !== undefined) lines.push(`drift since first report ${fmtMs(lagMs - firstLagMs)}`) + } + setLabel(panel, lines.join('\n')) +} + +function renderSidePanel(playback: PBAudioEvent | undefined, state: PBAudioEvent | undefined): void { + const lines = ['audioEventsSystem'] + lines.push(`position reports delivered ${positionReportCount}`) + lines.push(`state callbacks ${stateCallbackCount} push callback ${pushCallbackRegistered ? 'ON' : 'OFF'}`) + lines.push( + playback === undefined + ? 'getAudioPlayback: undefined' + : `getAudioPlayback: tick ${playback.tickNumber ?? 'n/a'} offset ${fmtSeconds(playback.currentOffset)} ` + + `length ${fmtSeconds(playback.clipLength)} age ${ + playback.tickNumber === undefined ? 'n/a' : `${currentTick - playback.tickNumber} ticks` + }` + ) + lines.push( + state === undefined + ? 'getAudioState: undefined' + : `getAudioState: ${mediaStateName(state.state)} @ts ${state.timestamp}` + ) + lines.push(`loop ${AudioSource.get(audioEntity).loop ? 'ON' : 'OFF'}`) + lines.push('state log (registerAudioEventsEntity):') + lines.push(...(stateLog.length > 0 ? stateLog : ['(none yet)'])) + setLabel(sidePanel, lines.join('\n')) +} + +// --------------------------------------------------------------------------- +// Push API: every report, position updates included +// --------------------------------------------------------------------------- +// One reading, timed two ways. The SDK hands over `sceneTime` already resolved against the tick the renderer +// sampled in, so the time the report spent in transit never enters the correct figure. Timing the same reading +// on arrival instead is the mistake this scene exists to show, and it is off by exactly that transit time. +function onPlaybackSample({ sceneTime, offset }: AudioPlaybackSample): void { + positionReportCount++ + lastReportWallMs = Date.now() + if (playSceneTimeS === undefined) return + + const expectedAtTick = (sceneTime - playSceneTimeS) * 1000 + clockOffsetMs + lagMs = expectedAtTick - offset * 1000 + if (firstLagMs === undefined) firstLagMs = lagMs + + const clockNow = sceneClockMs() + if (clockNow !== undefined) naiveLagMs = clockNow - offset * 1000 +} + +function setPushCallback(enabled: boolean): void { + if (enabled) audioEventsSystem.registerAudioPlaybackEntity(audioEntity, onPlaybackSample) + else audioEventsSystem.removeAudioPlaybackEntity(audioEntity) + pushCallbackRegistered = enabled +} +setPushCallback(true) + +// State-change API: must NOT fire for position reports that keep the same state +audioEventsSystem.registerAudioEventsEntity(audioEntity, (event) => { + stateCallbackCount++ + stateLog.push(`${mediaStateName(event.state)} @ts ${event.timestamp}`) + if (stateLog.length > 5) stateLog.shift() +}) + +// --------------------------------------------------------------------------- +// Buttons: three in front, two in the corner +// --------------------------------------------------------------------------- +function startClock(offsetMs: number): void { + const now = sdkSceneTimeMs() + playSceneTimeS = now === undefined ? undefined : now / 1000 + clockOffsetMs = offsetMs + lagMs = undefined + naiveLagMs = undefined + firstLagMs = undefined + for (const cube of [clockBeat, audioBeat, naiveBeat]) cube.lastSecond = -1 +} + +function addButton( + position: Vector3, + color: Color4, + caption: string, + hoverText: string, + onClick: () => void, + scale = 1.2 +): Entity { + const button = createButton(position, color, scale) + createLabel( + Vector3.create(position.x, position.y + scale + 0.4, position.z), + scale > 1 ? 2 : 1.5, + Color4.White(), + caption + ) + pointerEventsSystem.onPointerDown({ entity: button, opts: { button: InputAction.IA_POINTER, hoverText } }, onClick) + return button +} + +addButton(Vector3.create(5.5, 1, 4), Color4.create(0.2, 0.8, 0.2, 1), 'PLAY', 'Play from 0 (scene clock -> 0)', () => { + AudioSource.playSound(audioEntity, CLIP, true) + startClock(0) +}) +addButton( + Vector3.create(8, 1, 4), + Color4.create(0.2, 0.4, 0.9, 1), + `SEEK ${SEEK_TARGET_SECONDS}s`, + `Seek to ${SEEK_TARGET_SECONDS}s and play (scene clock -> 10 s)`, + () => { + // createOrReplace always emits a PUT, even when playing/currentTime are unchanged (SEEK pressed twice), so the + // renderer re-seeks every time. Hand-mutating via getMutable would be deduped when nothing changed. + AudioSource.createOrReplace(audioEntity, { + ...AudioSource.get(audioEntity), + playing: true, + currentTime: SEEK_TARGET_SECONDS + }) + startClock(SEEK_TARGET_SECONDS * 1000) + } +) +addButton(Vector3.create(10.5, 1, 4), Color4.create(0.8, 0.2, 0.2, 1), 'STOP', 'Stop (scene-initiated)', () => { + AudioSource.stopSound(audioEntity, true) +}) + +// Coverage controls, out of the way +addButton( + Vector3.create(14.6, 0.8, 2.2), + Color4.create(0.6, 0.4, 0.8, 1), + 'LOOP', + 'Toggle AudioSource.loop', + () => { + const source = AudioSource.getMutable(audioEntity) + source.loop = !source.loop + }, + 0.8 +) +addButton( + Vector3.create(14.6, 0.8, 4), + Color4.create(0.5, 0.5, 0.5, 1), + 'PUSH CB', + 'registerAudioPlaybackEntity / removeAudioPlaybackEntity / getSceneTimeAtTick', + () => { + setPushCallback(!pushCallbackRegistered) + }, + 0.8 +) + +// --------------------------------------------------------------------------- +// Per-frame: cubes and readouts +// --------------------------------------------------------------------------- +engine.addSystem(() => { + const playing = AudioSource.get(audioEntity).playing ?? false + const clock = sceneClockMs() + const wallNow = Date.now() + updateBeat(clockBeat, playing ? clock : undefined, wallNow) + updateBeat(audioBeat, playing && clock !== undefined && lagMs !== undefined ? clock - lagMs : undefined, wallNow) + updateBeat( + naiveBeat, + playing && clock !== undefined && naiveLagMs !== undefined ? clock - naiveLagMs : undefined, + wallNow + ) + setLabel( + headline, + lagMs === undefined + ? 'Press PLAY, then listen: which cube flashes with the beep?' + : `AUDIO flashes with the beep. CLOCK runs ${fmtMs(lagMs)} early. NAIVE is off by ${fmtMs((naiveLagMs ?? lagMs) - lagMs)}.` + ) + renderPanel() + renderSidePanel(audioEventsSystem.getAudioPlayback(audioEntity), audioEventsSystem.getAudioState(audioEntity)) +}) + +// --------------------------------------------------------------------------- +// Scene dressing +// --------------------------------------------------------------------------- +const ground = engine.addEntity() +Transform.create(ground, { position: Vector3.create(8, -0.05, 8), scale: Vector3.create(16, 0.1, 16) }) +MeshRenderer.setBox(ground) +Material.setPbrMaterial(ground, { albedoColor: Color4.create(0.15, 0.15, 0.15, 1) }) + +createLabel( + Vector3.create(8, 7.8, 9), + 2.2, + Color4.White(), + 'Audio Playback Position Reports\nThe renderer starts a clip late. Reports with a tickNumber let the scene measure by how much.' +) +void lastReportWallMs diff --git a/scenes/89,-12-audio-playback-position/tsconfig.json b/scenes/89,-12-audio-playback-position/tsconfig.json new file mode 100644 index 00000000..d84fa280 --- /dev/null +++ b/scenes/89,-12-audio-playback-position/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "allowJs": true, + "strict": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx" + ], + "extends": "@dcl/sdk/types/tsconfig.ecs7.json" +} \ No newline at end of file