diff --git a/package-lock.json b/package-lock.json index 1bce33a6..217f8dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3535,6 +3535,13 @@ "solid-js": "^1.8.6" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.109.0", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.109.0.tgz", @@ -4077,6 +4084,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.1.43", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.43.tgz", @@ -4088,6 +4106,13 @@ "@types/har-format": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-mediacapture-transform": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", @@ -4502,6 +4527,119 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", @@ -4951,6 +5089,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -5451,6 +5599,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6701,6 +6859,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6840,6 +7008,16 @@ "which": "bin/which" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9505,6 +9683,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10759,6 +10951,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11003,12 +11202,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stats.js": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -11317,6 +11530,13 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyest": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyest/-/tinyest-0.3.2.tgz", @@ -11339,6 +11559,16 @@ "node": ">=12.20.0" } }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -11384,6 +11614,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -11992,6 +12232,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -12150,6 +12493,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12482,7 +12842,8 @@ }, "devDependencies": { "@webgpu/types": "^0.1.71", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "vitest": "^4.1.11" } } } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6dfa3451..c7e0b10d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -12,7 +12,8 @@ "./media": "./src/media/index.ts" }, "scripts": { - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "test": "vitest run" }, "keywords": [ "koota", @@ -29,6 +30,7 @@ }, "devDependencies": { "@webgpu/types": "^0.1.71", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "vitest": "^4.1.11" } } diff --git a/packages/runtime/src/media/audio.test.ts b/packages/runtime/src/media/audio.test.ts new file mode 100644 index 00000000..1e5d1b28 --- /dev/null +++ b/packages/runtime/src/media/audio.test.ts @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, it, expect } from 'vitest'; +import { AudioDecoder } from './audio'; +import type { AudioAsset } from '@diffusionstudio/assets'; +import type { AudioBus } from './audio-bus'; +import type { WrappedAudioBuffer } from 'mediabunny'; + +/** + * Regression test for the play -> pause audio race: playback.ts fires `playTo()` + * fire-and-forget while playing, then synchronously calls `decoder.reset()` on pause + * without acquiring playTo's mutex. If reset() lands mid-await inside playTo, the + * resumed loop reads a nulled `this.iterator` -> TypeError. + * + * The "Deferred" mock iterator lets each `next()` be resolved manually, so the test + * can freeze `playTo` at the exact iteration where `reset()` runs. + */ + +function makeAsset(): AudioAsset { + return { id: 'test-audio', type: 'AUDIO', src: 'mem://x.mp3', channels: 2, sampleRate: 44100 } as unknown as AudioAsset; +} + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const playOptions = { + relativeFrom: 0, + relativeTo: 1, + trimStart: 0, + trimEnd: 10, + playbackRate: 1, + currentTime: 0, + relativeDelay: 0, +}; + +const fakeBus = { + context: { + currentTime: 10, + sampleRate: 44100, + createBufferSource: () => ({ connect() {}, start() {}, stop() {} }), + createGain: () => ({ gain: { value: 1 }, connect() {} }), + createMediaElementSource: () => ({ connect() {} }), + destination: {}, + }, + input: {}, +} as unknown as AudioBus; + +/** A decoded audio packet. */ +function sample(timestamp: number): WrappedAudioBuffer { + return { + timestamp, + duration: 0.5, + buffer: { + sampleRate: 44100, + numberOfChannels: 2, + getChannelData: (_c: number) => new Float32Array(1), + }, + } as WrappedAudioBuffer; +} + +interface DecoderInternals { + iterator: AsyncGenerator | null; + firstBuffer: WrappedAudioBuffer | null; + lastBuffer: WrappedAudioBuffer | null; + stretcher: { append(...args: unknown[]): unknown; finalize(): unknown } | null; + nextTimestamp: number; + audioNodes: Set; + sink: unknown; +} + +/** Routes playTo down the "reuse existing iterator" branch, so it loops on our + * injected iterator instead of re-seeding from this.sink (unset without init()). */ +function setupDecoder(): { decoder: AudioDecoder; internals: DecoderInternals } { + const decoder = new AudioDecoder(makeAsset()); + const internals = decoder as unknown as DecoderInternals; + + internals.audioNodes = new Set(); + internals.nextTimestamp = 0; + const seed = sample(0); + internals.firstBuffer = seed; + internals.lastBuffer = seed; + internals.stretcher = { append: () => null, finalize: () => null }; + + return { decoder, internals }; +} + +/** Mirrors the fire-and-forget call in systems/playback.ts:188. */ +function startPlayTo(decoder: AudioDecoder, bus: AudioBus = fakeBus): Promise { + return (decoder.playTo as (bus: AudioBus, o: typeof playOptions) => Promise) + .call(decoder, bus, playOptions) + .then( + () => null, + (err) => err as Error, + ); +} + +describe('AudioDecoder.playTo vs reset race', () => { + it('does not dereference a nulled iterator when reset() runs mid-playTo', async () => { + const { decoder, internals } = setupDecoder(); + + // Each `next()` returns a manually-resolvable promise, so the test can freeze + // `playTo` at a chosen iteration and interleave a synchronous `reset()`. + const nextQueue: Array> = []; + internals.iterator = { + next: () => { + const d = deferred<{ value: WrappedAudioBuffer | undefined; done: boolean }>(); + nextQueue.push(d); + return d.promise; + }, + return: () => + Promise.resolve({ value: undefined, done: true }), + } as never; + + const playPromise = startPlayTo(decoder); + + // Iteration 1: packet below relativeTo(1), so the loop goes around again. + await waitForNext(nextQueue); + const iter1 = nextQueue.shift()!; + iter1.resolve({ value: sample(0.5), done: false }); + await waitForNext(nextQueue); + + // Iteration 2: playTo is suspended here when the user pauses. + const iter2 = nextQueue.shift()!; + decoder.reset(); + + // Resolve below relativeTo(1) too, forcing a third iteration that (pre-fix) + // would read the now-nulled `this.iterator` and throw. + iter2.resolve({ value: sample(0.5), done: false }); + + const error = await playPromise; + expect(error).toBeNull(); + }); + + it('gracefully swallows a decoder-abort rejection when reset() ran mid-await', async () => { + const { decoder, internals } = setupDecoder(); + + // Pending next() rejects with a teardown error after reset() has already + // nulled `this.iterator` underneath it. + let rejectNext!: (err: Error) => void; + internals.iterator = { + next: () => new Promise((_, reject) => { + rejectNext = reject; + }), + return: () => Promise.resolve({ value: undefined, done: true }), + } as never; + + const playPromise = startPlayTo(decoder); + + await waitForReject(internals, () => rejectNext); + decoder.reset(); + rejectNext(new Error('Worker terminated')); + + expect(await playPromise).toBeNull(); + }); + + it('does not resurrect a decode when reset() lands while a queued playTo waits on the mutex', async () => { + const { decoder, internals } = setupDecoder(); + + // Count every audio node the decoder schedules, so the test can assert that a + // paused decoder never starts playback. + const started: number[] = []; + const bus = { + context: { + ...(fakeBus as unknown as { context: Record }).context, + createBufferSource: () => ({ + connect() {}, + start(when: number) { started.push(when); }, + stop() {}, + }), + }, + input: {}, + } as unknown as AudioBus; + + const queueA: Array> = []; + internals.iterator = { + next: () => { + const d = deferred<{ value: WrappedAudioBuffer | undefined; done: boolean }>(); + queueA.push(d); + return d.promise; + }, + return: () => Promise.resolve({ value: undefined, done: true }), + } as never; + + // A holds the mutex, suspended on next(). + const a = startPlayTo(decoder, bus); + await waitForNext(queueA); + + // B is queued behind the mutex, and cannot observe anything about the reset + // below from the iterator alone: it is null both before and after. + const b = startPlayTo(decoder, bus); + + let reseeds = 0; + internals.sink = { + buffers: () => { + reseeds++; + return { + next: () => Promise.resolve({ value: undefined, done: true }), + return: () => Promise.resolve({ value: undefined, done: true }), + }; + }, + }; + + // User pauses. + decoder.reset(); + + // A resumes, notices the iterator changed, breaks, and releases the mutex to B. + queueA.shift()!.resolve({ value: sample(0.5), done: false }); + + expect(await a).toBeNull(); + expect(await b).toBeNull(); + + expect(reseeds).toBe(0); + expect(internals.iterator).toBeNull(); + expect(started).toEqual([]); + }); +}); + +// Every pre-loop `await` in `playTo` is a microtask (the AsyncMutex chains +// `Promise.resolve()`), so a fixed number of microtask yields deterministically +// reaches the point where `playTo` is suspended on `await iterator.next()`. + +async function waitForReject( + _internals: DecoderInternals, + getReject: () => (err: Error) => void, +): Promise { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + if (typeof getReject() === 'function') return; + } + throw new Error('playTo never suspended on iterator.next()'); +} + +async function waitForNext( + queue: Array>, +): Promise { + for (let i = 0; i < 8 && queue.length === 0; i++) { + await Promise.resolve(); + } + expect(queue.length).toBeGreaterThan(0); +} diff --git a/packages/runtime/src/media/audio.ts b/packages/runtime/src/media/audio.ts index df05a8dd..5ad8bf8f 100644 --- a/packages/runtime/src/media/audio.ts +++ b/packages/runtime/src/media/audio.ts @@ -64,6 +64,7 @@ export class AudioDecoder { private readonly audioNodes = new Set(); private iterator: AsyncGenerator | null = null; + private generation = 0; private firstBuffer: WrappedAudioBuffer | null = null; private lastBuffer: WrappedAudioBuffer | null = null; private stretcher: TimeStretcher | null = null; @@ -156,6 +157,10 @@ export class AudioDecoder { const { relativeFrom, relativeTo } = options; + // Sampled before the mutex so a reset() that lands while we are queued behind + // another playTo is still observed: by then the iterator is null both before + // and after the reset, so identity alone cannot detect it. + const generation = this.generation; const release = await this.mutex.acquire(); const firstTs = this.firstBuffer?.timestamp ?? Number.POSITIVE_INFINITY; @@ -163,9 +168,12 @@ export class AudioDecoder { const isBufferOutOfRange = relativeFrom < firstTs || lastTs < relativeFrom - 1; try { + if (this.generation !== generation) return; + if (this.iterator == null || this.stretcher == null || isBufferOutOfRange) { // Close the previous iterator so its pre-decoded AudioSamples get released. await this.iterator?.return(); + if (this.generation !== generation) return; this.iterator = this.sink.buffers(relativeFrom); this.firstBuffer = null; this.lastBuffer = null; @@ -173,7 +181,23 @@ export class AudioDecoder { } while (true) { - const nextBuffer = (await this.iterator.next()).value; + // reset() nulls this.iterator without acquiring the mutex, so it can run + // while we're suspended below. Snapshot it and re-check identity after + // each await instead of trusting this.iterator directly. + const iterator: AsyncGenerator | null = this.iterator; + if (iterator == null) break; + + let result: IteratorResult; + try { + result = await iterator.next(); + } catch (err) { + // A concurrent reset() can cause the pending next() to reject. + if (this.iterator !== iterator) break; + throw err; + } + + if (this.iterator !== iterator) break; + const nextBuffer = result.value; // Use the actual decoded sample rate if available const sampleRate = nextBuffer?.buffer.sampleRate ?? this.asset.sampleRate; const numberOfChannels = nextBuffer?.buffer.numberOfChannels ?? this.asset.channels; @@ -228,6 +252,10 @@ export class AudioDecoder { } public reset() { + // Bumped unconditionally: an in-flight playTo must be invalidated even when + // there is no iterator left to null out. + this.generation++; + if (!this.iterator) return; this.iterator?.return();