diff --git a/.changeset/lazy-rings-cough.md b/.changeset/lazy-rings-cough.md new file mode 100644 index 0000000..3ac7ecc --- /dev/null +++ b/.changeset/lazy-rings-cough.md @@ -0,0 +1,19 @@ +--- +'earwurm': minor +--- + +Bump node to `18.14.2`. + +Bump various dependencies. + +Fix issue with `Earworm > state` being set to `suspended` even after `closed`. + +Fix bug with `Sound` throwing an error on subsequent calls to `.play()`. + +Fix bug with `Sound > pause()` not working. + +Fix bug with `volume` and `mute` setters not actually changing `gain.value`. + +Both `Stack` and `Sound` can now accept a `GainNode` _(in addition to an `AudioNode`)_ as their `destination`. + +Simplify exported `types`. diff --git a/.nvmrc b/.nvmrc index 617bcf9..72e4a48 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.14.1 +18.14.2 diff --git a/docs/roadmap.md b/docs/roadmap.md index 3f23d96..523324d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,3 +64,19 @@ While we are intentionally keeping the scope of `Earwurm` to an absolute minimum - Example: This would allow me to know what `Stack` manages a particular `Sound`. 16. Consider setting `Stack > state` to `closed` on `teardown`. - This way, there is a better mechanism to removing a `Stack` after listening to a `statechange`. + +### Additional + +Maybe there is some value in passing an array of `GainNode/AudioNode` for `destination`? If so, that might look something like: + +```ts +export type AudioOutputs = [...GainNode[], AudioNode]; + +constructor(readonly outputs: AudioOutputs); + +let lastConnection: GainNode | AudioNode = this.#gainNode; + +outputs.forEach((node) => { + lastConnection = lastConnection.connect(node); +}); +``` diff --git a/package-lock.json b/package-lock.json index 4c3a7b7..9961ed6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,10 @@ "devDependencies": { "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.0", - "@types/node": "^18.14.0", - "@typescript-eslint/eslint-plugin": "^5.52.0", - "@vitest/coverage-c8": "^0.28.5", - "@vitest/ui": "^0.28.5", + "@types/node": "^18.14.1", + "@typescript-eslint/eslint-plugin": "^5.53.0", + "@vitest/coverage-c8": "^0.29.1", + "@vitest/ui": "^0.29.1", "eslint": "^8.34.0", "eslint-config-prettier": "^8.6.0", "eslint-config-standard-with-typescript": "^34.0.0", @@ -25,11 +25,11 @@ "eslint-plugin-n": "^15.6.1", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", - "happy-dom": "^8.4.4", + "happy-dom": "^8.9.0", "typescript": "^4.9.5", - "vite": "^4.1.2", - "vite-plugin-dts": "^1.7.2", - "vitest": "^0.28.5" + "vite": "^4.1.4", + "vite-plugin-dts": "^2.0.2", + "vitest": "^0.29.1" }, "engines": { "node": ">=18.0.0" @@ -73,10 +73,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.21.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.2.tgz", + "integrity": "sha512-URpaIJQwEkEC2T9Kn+Ai6Xe/02iNaVCuT/PtoRz3GPVJVDpPd7mLo+VddTbhCRU9TXqW5mSrQfXZyi8kDKOVpQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { - "version": "7.20.13", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz", - "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", + "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", "dev": true, "dependencies": { "regenerator-runtime": "^0.13.11" @@ -1176,9 +1188,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.14.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz", - "integrity": "sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A==", + "version": "18.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz", + "integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==", "dev": true }, "node_modules/@types/normalize-package-data": { @@ -1194,14 +1206,14 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.52.0.tgz", - "integrity": "sha512-lHazYdvYVsBokwCdKOppvYJKaJ4S41CgKBcPvyd0xjZNbvQdhn/pnJlGtQksQ/NhInzdaeaSarlBjDXHuclEbg==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.53.0.tgz", + "integrity": "sha512-alFpFWNucPLdUOySmXCJpzr6HKC3bu7XooShWM+3w/EL6J2HIoB2PFxpLnq4JauWVk6DiVeNKzQlFEaE+X9sGw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.52.0", - "@typescript-eslint/type-utils": "5.52.0", - "@typescript-eslint/utils": "5.52.0", + "@typescript-eslint/scope-manager": "5.53.0", + "@typescript-eslint/type-utils": "5.53.0", + "@typescript-eslint/utils": "5.53.0", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", @@ -1243,14 +1255,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.52.0.tgz", - "integrity": "sha512-e2KiLQOZRo4Y0D/b+3y08i3jsekoSkOYStROYmPUnGMEoA0h+k2qOH5H6tcjIc68WDvGwH+PaOrP1XRzLJ6QlA==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.53.0.tgz", + "integrity": "sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.52.0", - "@typescript-eslint/types": "5.52.0", - "@typescript-eslint/typescript-estree": "5.52.0", + "@typescript-eslint/scope-manager": "5.53.0", + "@typescript-eslint/types": "5.53.0", + "@typescript-eslint/typescript-estree": "5.53.0", "debug": "^4.3.4" }, "engines": { @@ -1270,13 +1282,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.52.0.tgz", - "integrity": "sha512-AR7sxxfBKiNV0FWBSARxM8DmNxrwgnYMPwmpkC1Pl1n+eT8/I2NAUPuwDy/FmDcC6F8pBfmOcaxcxRHspgOBMw==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.53.0.tgz", + "integrity": "sha512-Opy3dqNsp/9kBBeCPhkCNR7fmdSQqA+47r21hr9a14Bx0xnkElEQmhoHga+VoaoQ6uDHjDKmQPIYcUcKJifS7w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.52.0", - "@typescript-eslint/visitor-keys": "5.52.0" + "@typescript-eslint/types": "5.53.0", + "@typescript-eslint/visitor-keys": "5.53.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1287,13 +1299,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.52.0.tgz", - "integrity": "sha512-tEKuUHfDOv852QGlpPtB3lHOoig5pyFQN/cUiZtpw99D93nEBjexRLre5sQZlkMoHry/lZr8qDAt2oAHLKA6Jw==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.53.0.tgz", + "integrity": "sha512-HO2hh0fmtqNLzTAme/KnND5uFNwbsdYhCZghK2SoxGp3Ifn2emv+hi0PBUjzzSh0dstUIFqOj3bp0AwQlK4OWw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "5.52.0", - "@typescript-eslint/utils": "5.52.0", + "@typescript-eslint/typescript-estree": "5.53.0", + "@typescript-eslint/utils": "5.53.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -1314,9 +1326,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.52.0.tgz", - "integrity": "sha512-oV7XU4CHYfBhk78fS7tkum+/Dpgsfi91IIDy7fjCyq2k6KB63M6gMC0YIvy+iABzmXThCRI6xpCEyVObBdWSDQ==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.53.0.tgz", + "integrity": "sha512-5kcDL9ZUIP756K6+QOAfPkigJmCPHcLN7Zjdz76lQWWDdzfOhZDTj1irs6gPBKiXx5/6O3L0+AvupAut3z7D2A==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1327,13 +1339,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.52.0.tgz", - "integrity": "sha512-WeWnjanyEwt6+fVrSR0MYgEpUAuROxuAH516WPjUblIrClzYJj0kBbjdnbQXLpgAN8qbEuGywiQsXUVDiAoEuQ==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.53.0.tgz", + "integrity": "sha512-eKmipH7QyScpHSkhbptBBYh9v8FxtngLquq292YTEQ1pxVs39yFBlLC1xeIZcPPz1RWGqb7YgERJRGkjw8ZV7w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.52.0", - "@typescript-eslint/visitor-keys": "5.52.0", + "@typescript-eslint/types": "5.53.0", + "@typescript-eslint/visitor-keys": "5.53.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1369,16 +1381,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.52.0.tgz", - "integrity": "sha512-As3lChhrbwWQLNk2HC8Ree96hldKIqk98EYvypd3It8Q1f8d5zWyIoaZEp2va5667M4ZyE7X8UUR+azXrFl+NA==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.53.0.tgz", + "integrity": "sha512-VUOOtPv27UNWLxFwQK/8+7kvxVC+hPHNsJjzlJyotlaHjLSIgOCKj9I0DBUjwOOA64qjBwx5afAPjksqOxMO0g==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.52.0", - "@typescript-eslint/types": "5.52.0", - "@typescript-eslint/typescript-estree": "5.52.0", + "@typescript-eslint/scope-manager": "5.53.0", + "@typescript-eslint/types": "5.53.0", + "@typescript-eslint/typescript-estree": "5.53.0", "eslint-scope": "^5.1.1", "eslint-utils": "^3.0.0", "semver": "^7.3.7" @@ -1416,12 +1428,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.52.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.52.0.tgz", - "integrity": "sha512-qMwpw6SU5VHCPr99y274xhbm+PRViK/NATY6qzt+Et7+mThGuFSl/ompj2/hrBlRP/kq+BFdgagnOSgw9TB0eA==", + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.53.0.tgz", + "integrity": "sha512-JqNLnX3leaHFZEN0gCh81sIvgrp/2GOACZNgO4+Tkf64u51kTpAyWFOY8XHx8XuXr3N2C9zgPPHtcpMg6z1g0w==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.52.0", + "@typescript-eslint/types": "5.53.0", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -1433,38 +1445,40 @@ } }, "node_modules/@vitest/coverage-c8": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-c8/-/coverage-c8-0.28.5.tgz", - "integrity": "sha512-zCNyurjudoG0BAqAgknvlBhkV2V9ZwyYLWOAGtHSDhL/St49MJT+V2p1G0yPaoqBbKOTATVnP5H2p1XL15H75g==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-c8/-/coverage-c8-0.29.1.tgz", + "integrity": "sha512-YQZp1xGNxOcZD/zQBvD4bNpUDMJW7+FhBAEBlgvJp+DQ+aNK+dKcoWOTfsod27KQhXSr6UUYI8EYXWCOQqY6Eg==", "dev": true, "dependencies": { - "c8": "^7.12.0", + "c8": "^7.13.0", "picocolors": "^1.0.0", - "std-env": "^3.3.1", - "vitest": "0.28.5" + "std-env": "^3.3.1" }, "funding": { "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vitest": ">=0.29.0 <1" } }, "node_modules/@vitest/expect": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.28.5.tgz", - "integrity": "sha512-gqTZwoUTwepwGIatnw4UKpQfnoyV0Z9Czn9+Lo2/jLIt4/AXLTn+oVZxlQ7Ng8bzcNkR+3DqLJ08kNr8jRmdNQ==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.29.1.tgz", + "integrity": "sha512-VFt1u34D+/L4pqjLA8VGPdHbdF8dgjX9Nq573L9KG6/7MIAL9jmbEIKpXudmxjoTwcyczOXRyDuUWBQHZafjoA==", "dev": true, "dependencies": { - "@vitest/spy": "0.28.5", - "@vitest/utils": "0.28.5", + "@vitest/spy": "0.29.1", + "@vitest/utils": "0.29.1", "chai": "^4.3.7" } }, "node_modules/@vitest/runner": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.28.5.tgz", - "integrity": "sha512-NKkHtLB+FGjpp5KmneQjTcPLWPTDfB7ie+MmF1PnUBf/tGe2OjGxWyB62ySYZ25EYp9krR5Bw0YPLS/VWh1QiA==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.29.1.tgz", + "integrity": "sha512-VZ6D+kWpd/LVJjvxkt79OA29FUpyrI5L/EEwoBxH5m9KmKgs1QWNgobo/CGQtIWdifLQLvZdzYEK7Qj96w/ixQ==", "dev": true, "dependencies": { - "@vitest/utils": "0.28.5", + "@vitest/utils": "0.29.1", "p-limit": "^4.0.0", "pathe": "^1.1.0" } @@ -1485,18 +1499,18 @@ } }, "node_modules/@vitest/spy": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.28.5.tgz", - "integrity": "sha512-7if6rsHQr9zbmvxN7h+gGh2L9eIIErgf8nSKYDlg07HHimCxp4H6I/X/DPXktVPPLQfiZ1Cw2cbDIx9fSqDjGw==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.29.1.tgz", + "integrity": "sha512-sRXXK44pPzaizpiZOIQP7YMhxIs80J/b6v1yR3SItpxG952c8tdA7n0O2j4OsVkjiO/ZDrjAYFrXL3gq6hLx6Q==", "dev": true, "dependencies": { "tinyspy": "^1.0.2" } }, "node_modules/@vitest/ui": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.28.5.tgz", - "integrity": "sha512-hzzZzv38mH/LMFh54QEJpWFuGixZZBOD+C0fHU81d1lsvochPwNZhWJbuRJQNyZLSMZYCYW4hF6PpNQJXDHDmg==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.29.1.tgz", + "integrity": "sha512-CtcFlqcxNFT+5geTYfjOMOje4iae2DGsOnW1//7cre2xc43mFVOJYG7ZPq1wOUisDqSCyPLP2+zvAAlFJxBiUA==", "dev": true, "dependencies": { "fast-glob": "^3.2.12", @@ -1507,9 +1521,9 @@ } }, "node_modules/@vitest/utils": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.28.5.tgz", - "integrity": "sha512-UyZdYwdULlOa4LTUSwZ+Paz7nBHGTT72jKwdFSV4IjHF1xsokp+CabMdhjvVhYwkLfO88ylJT46YMilnkSARZA==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.29.1.tgz", + "integrity": "sha512-6npOEpmyE6zPS2wsWb7yX5oDpp6WY++cC5BX6/qaaMhGC3ZlPd8BbTz3RtGPi1PfPerPvfs4KqS/JDOIaB6J3w==", "dev": true, "dependencies": { "cli-truncate": "^3.1.0", @@ -1747,12 +1761,6 @@ "wcwidth": "^1.0.1" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, "node_modules/builtins": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", @@ -3566,19 +3574,32 @@ "dev": true }, "node_modules/happy-dom": { - "version": "8.4.4", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-8.4.4.tgz", - "integrity": "sha512-v0RbFlIIApDN8D7HOlCJWKE4BiRZgidM6I0v/1//ctDLkJcgvitgTw/AZ3S2BPN5Z9cMaVLnOIuvakJhf8dojw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-8.9.0.tgz", + "integrity": "sha512-JZwJuGdR7ko8L61136YzmrLv7LgTh5b8XaEM3P709mLjyQuXJ3zHTDXvUtBBahRjGlcYW0zGjIiEWizoTUGKfA==", "dev": true, "dependencies": { "css.escape": "^1.5.1", "he": "^1.2.0", + "iconv-lite": "^0.6.3", "node-fetch": "^2.x.x", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0" } }, + "node_modules/happy-dom/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -4371,6 +4392,18 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.29.0.tgz", + "integrity": "sha512-WcfidHrDjMY+eLjlU+8OvwREqHwpgCeKVBUpQ3OhYYuvfaYCUgcbuBzappNzZvg/v8onU3oQj+BYpkOJe9Iw4Q==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -5325,9 +5358,9 @@ } }, "node_modules/rollup": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.16.0.tgz", - "integrity": "sha512-9wE1H5N1SJqnROpQanBGJ7lrIitwlUYGj4Va4eyf3+vNhoIHLPLag2/CUGIiq3V9lHOBJB6zTsGsPvc50oeihg==", + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.17.3.tgz", + "integrity": "sha512-p5LaCXiiOL/wrOkj8djsIDFmyU9ysUxcyW+EKRLHb6TKldJzXpImjcRSR+vgo09DBdofGcOoLOsRyxxG2n5/qQ==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -5662,16 +5695,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/spawndamnit": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-2.0.0.tgz", @@ -6078,13 +6101,13 @@ } }, "node_modules/tsconfig-paths": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", - "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", + "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", - "json5": "^1.0.1", + "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } @@ -6245,9 +6268,9 @@ } }, "node_modules/tty-table/node_modules/yargs": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.0.tgz", - "integrity": "sha512-dwqOPg5trmrre9+v8SUo2q/hAwyKoVfu8OC1xPHKJGNdxAvPl4sKxL4vBnh3bQz/ZvvGAFeA5H3ou2kcOY8sQQ==", + "version": "17.7.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", + "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", "dev": true, "dependencies": { "cliui": "^8.0.1", @@ -6404,9 +6427,9 @@ } }, "node_modules/vite": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.1.2.tgz", - "integrity": "sha512-MWDb9Rfy3DI8omDQySbMK93nQqStwbsQWejXRY2EBzEWKmLAXWb1mkI9Yw2IJrc+oCvPCI1Os5xSSIBYY6DEAw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.1.4.tgz", + "integrity": "sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==", "dev": true, "dependencies": { "esbuild": "^0.16.14", @@ -6453,9 +6476,9 @@ } }, "node_modules/vite-node": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.28.5.tgz", - "integrity": "sha512-LmXb9saMGlrMZbXTvOveJKwMTBTNUH66c8rJnQ0ZPNX+myPEol64+szRzXtV5ORb0Hb/91yq+/D3oERoyAt6LA==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.29.1.tgz", + "integrity": "sha512-Ey9bTlQOQrCxQN0oJ7sTg+GrU4nTMLg44iKTFCKf31ry60csqQz4E+Q04hdWhwE4cTgpxUC+zEB1kHbf5jNkFA==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -6463,8 +6486,6 @@ "mlly": "^1.1.0", "pathe": "^1.1.0", "picocolors": "^1.0.0", - "source-map": "^0.6.1", - "source-map-support": "^0.5.21", "vite": "^3.0.0 || ^4.0.0" }, "bin": { @@ -6478,11 +6499,12 @@ } }, "node_modules/vite-plugin-dts": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-1.7.3.tgz", - "integrity": "sha512-u3t45p6fTbzUPMkwYe0ESwuUeiRMlwdPfD3dRyDKUwLe2WmEYcFyVp2o9/ke2EMrM51lQcmNWdV9eLcgjD1/ng==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-2.0.2.tgz", + "integrity": "sha512-i3HBlrdqE2FQxQqrNwFj9P2ei/I7lt/d3Q8NOE1JCz/gNYhNf/oUeIJamIdWQUNQGhUd/Y6mtpm3kOYPw1gz8Q==", "dev": true, "dependencies": { + "@babel/parser": "^7.20.15", "@microsoft/api-extractor": "^7.33.5", "@rollup/pluginutils": "^5.0.2", "@rushstack/node-core-library": "^3.53.2", @@ -6490,6 +6512,7 @@ "fast-glob": "^3.2.12", "fs-extra": "^10.1.0", "kolorist": "^1.6.0", + "magic-string": "^0.29.0", "ts-morph": "17.0.1" }, "engines": { @@ -6535,18 +6558,18 @@ } }, "node_modules/vitest": { - "version": "0.28.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.28.5.tgz", - "integrity": "sha512-pyCQ+wcAOX7mKMcBNkzDwEHRGqQvHUl0XnoHR+3Pb1hytAHISgSxv9h0gUiSiYtISXUU3rMrKiKzFYDrI6ZIHA==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.29.1.tgz", + "integrity": "sha512-iSy6d9VwsIn7pz5I8SjVwdTLDRGKNZCRJVzROwjt0O0cffoymKwazIZ2epyMpRGpeL5tsXAl1cjXiT7agTyVug==", "dev": true, "dependencies": { "@types/chai": "^4.3.4", "@types/chai-subset": "^1.3.3", "@types/node": "*", - "@vitest/expect": "0.28.5", - "@vitest/runner": "0.28.5", - "@vitest/spy": "0.28.5", - "@vitest/utils": "0.28.5", + "@vitest/expect": "0.29.1", + "@vitest/runner": "0.29.1", + "@vitest/spy": "0.29.1", + "@vitest/utils": "0.29.1", "acorn": "^8.8.1", "acorn-walk": "^8.2.0", "cac": "^6.7.14", @@ -6562,7 +6585,7 @@ "tinypool": "^0.3.1", "tinyspy": "^1.0.2", "vite": "^3.0.0 || ^4.0.0", - "vite-node": "0.28.5", + "vite-node": "0.29.1", "why-is-node-running": "^2.2.2" }, "bin": { diff --git a/package.json b/package.json index 5554cf0..76b42d4 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,10 @@ "devDependencies": { "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.0", - "@types/node": "^18.14.0", - "@typescript-eslint/eslint-plugin": "^5.52.0", - "@vitest/coverage-c8": "^0.28.5", - "@vitest/ui": "^0.28.5", + "@types/node": "^18.14.1", + "@typescript-eslint/eslint-plugin": "^5.53.0", + "@vitest/coverage-c8": "^0.29.1", + "@vitest/ui": "^0.29.1", "eslint": "^8.34.0", "eslint-config-prettier": "^8.6.0", "eslint-config-standard-with-typescript": "^34.0.0", @@ -68,11 +68,11 @@ "eslint-plugin-n": "^15.6.1", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", - "happy-dom": "^8.4.4", + "happy-dom": "^8.9.0", "typescript": "^4.9.5", - "vite": "^4.1.2", - "vite-plugin-dts": "^1.7.2", - "vitest": "^0.28.5" + "vite": "^4.1.4", + "vite-plugin-dts": "^2.0.2", + "vitest": "^0.29.1" }, "peerDependencies": { "emitten": "^0.4.2" diff --git a/src/Earwurm.ts b/src/Earwurm.ts index 457cf68..2cf8b46 100644 --- a/src/Earwurm.ts +++ b/src/Earwurm.ts @@ -26,7 +26,6 @@ export class Earwurm extends EmittenCommon { #context = new AudioContext(); #gainNode = this.#context.createGain(); - #outputNode: AudioNode; #fadeSec = 0; #request: ManagerConfig['request']; @@ -44,8 +43,8 @@ export class Earwurm extends EmittenCommon { this._volume = config?.volume ?? this._volume; this.#fadeSec = config?.fadeMs ? msToSec(config.fadeMs) : this.#fadeSec; this.#request = config?.request ?? undefined; - this.#outputNode = this.#gainNode.connect(this.#context.destination); + this.#gainNode.connect(this.#context.destination); this.#gainNode.gain.setValueAtTime(this._volume, this.#context.currentTime); this.#autoSuspend(); @@ -127,7 +126,7 @@ export class Earwurm extends EmittenCommon { const newStacks = entries.map(({id, path}) => { newKeys.push(id); - const newStack = new Stack(id, path, this.#context, this.#outputNode, { + const newStack = new Stack(id, path, this.#context, this.#gainNode, { fadeMs: secToMs(this.#fadeSec), request: this.#request, }); @@ -263,7 +262,12 @@ export class Earwurm extends EmittenCommon { this.#setState('suspending'); const resolveSuspension = () => { - this.#setState('suspended'); + if (this._state !== 'closed') { + // Because all of these `AudioContext > state` + // methods are async, we need to make sure we don't + // set `suspended` after already `closed`. + this.#setState('suspended'); + } if (this.#suspendId) clearTimeout(this.#suspendId); this.#suspendId = 0; diff --git a/src/Sound.ts b/src/Sound.ts index e567b38..ee00852 100644 --- a/src/Sound.ts +++ b/src/Sound.ts @@ -1,6 +1,7 @@ import {EmittenCommon} from 'emitten'; import {clamp, msToSec} from './utilities'; +import {tokens} from './tokens'; import type {SoundId, SoundState, SoundEventMap, SoundConfig} from './types'; export class Sound extends EmittenCommon { @@ -12,14 +13,14 @@ export class Sound extends EmittenCommon { // "True private" properties #source: AudioBufferSourceNode; #gainNode: GainNode; - #outputNode: AudioNode; #fadeSec = 0; + #started = false; constructor( readonly id: SoundId, readonly buffer: AudioBuffer, readonly context: AudioContext, - readonly destination: AudioNode, + readonly destination: GainNode | AudioNode, config?: SoundConfig, ) { super(); @@ -27,12 +28,11 @@ export class Sound extends EmittenCommon { this._volume = config?.volume ?? this._volume; this.#fadeSec = config?.fadeMs ? msToSec(config.fadeMs) : this.#fadeSec; - this.#source = this.context.createBufferSource(); this.#gainNode = this.context.createGain(); - this.#outputNode = this.#gainNode.connect(this.destination); + this.#source = this.context.createBufferSource(); this.#source.buffer = buffer; - this.#source.connect(this.#outputNode); + this.#source.connect(this.#gainNode).connect(this.destination); this.#gainNode.gain.setValueAtTime(this._volume, this.context.currentTime); // We could `emit` a "created" event, but it wouldn't get caught @@ -97,18 +97,31 @@ export class Sound extends EmittenCommon { } play() { - this.#source.start(); + if (!this.#started) { + this.#source.start(); + this.#started = true; + } + + if (this._state === 'paused') { + this.#source.playbackRate.value = 1; + this.mute = false; + } + this.#setState('playing'); return this; } pause() { - // There is no `pause/resume` API for a `AudioBufferSourceNode`, - // so we may have to set `playbackRate.value = 0` instead. + if (this._state === 'paused') return this; + + // There is no `pause/resume` API for a `AudioBufferSourceNode`. + // Lowering the `playbackRate` isn't ideal as technically the + // audio is still playing in the background and using resources. // https://github.com/WebAudio/web-audio-api-v2/issues/105 + this.#source.playbackRate.value = tokens.minPlaybackRate; + this.mute = true; - this.#source.playbackRate.value = 0; this.#setState('paused'); return this; @@ -131,6 +144,8 @@ export class Sound extends EmittenCommon { } #handleEnded = () => { + // Intentionally not setting `stopping` state here, + // but we may want ot consider a "ending" state instead. this.emit('ended', {id: this.id, source: this.#source}); }; } diff --git a/src/Stack.ts b/src/Stack.ts index deb951e..e250901 100644 --- a/src/Stack.ts +++ b/src/Stack.ts @@ -11,7 +11,7 @@ import type { StackEventMap, StackConfig, SoundId, - SoundEndedEvent, + SoundEventMap, } from './types'; import {Sound} from './Sound'; @@ -34,7 +34,6 @@ export class Stack extends EmittenCommon { private _state: StackState = 'idle'; #gainNode: GainNode; - #outputNode: AudioNode; #fadeSec = 0; #totalSoundsCreated = 0; #request: StackConfig['request']; @@ -44,7 +43,7 @@ export class Stack extends EmittenCommon { readonly id: StackId, readonly path: string, readonly context: AudioContext, - readonly destination: AudioNode, + readonly destination: GainNode | AudioNode, config?: StackConfig, ) { super(); @@ -54,8 +53,8 @@ export class Stack extends EmittenCommon { this.#request = config?.request ?? undefined; this.#gainNode = this.context.createGain(); - this.#outputNode = this.#gainNode.connect(this.destination); + this.#gainNode.connect(this.destination); this.#gainNode.gain.setValueAtTime(this._volume, this.context.currentTime); } @@ -173,11 +172,11 @@ export class Stack extends EmittenCommon { } #create(id: SoundId, buffer: AudioBuffer) { - const newSound = new Sound(id, buffer, this.context, this.#outputNode, { + const newSound = new Sound(id, buffer, this.context, this.#gainNode, { fadeMs: secToMs(this.#fadeSec), }); - newSound.on('statechange', this.#handleStateFromQueue); + newSound.on('statechange', this.#handleSoundState); newSound.once('ended', this.#handleSoundEnded); // We do not filter out identical `id` values, @@ -219,7 +218,15 @@ export class Stack extends EmittenCommon { this.#setState(this.playing ? 'playing' : 'idle'); }; - #handleSoundEnded = (event: SoundEndedEvent) => { + #handleSoundState: SoundEventMap['statechange'] = (_state) => { + this.#handleStateFromQueue(); + }; + + #handleSoundEnded: SoundEventMap['ended'] = (event) => { this.#setQueue(this.#queue.filter(({id}) => id !== event.id)); + + // We only set `stopping` state when `.stop()` is called. + // There is no `statechange` specifically for "ended". + this.#handleStateFromQueue(); }; } diff --git a/src/tests/Earwurm.test.ts b/src/tests/Earwurm.test.ts index a339de0..b7a863f 100644 --- a/src/tests/Earwurm.test.ts +++ b/src/tests/Earwurm.test.ts @@ -1,8 +1,110 @@ -import {describe, it, expect} from 'vitest'; +import {describe, it, expect, vi} from 'vitest'; + import {Earwurm} from '../Earwurm'; +import {tokens} from '../tokens'; +import type {ManagerEventMap} from '../types'; +import {mockData} from './mock'; describe('Earwurm component', () => { - it('is defined', () => { - expect(Earwurm).not.toBeNull(); + describe('initialization', () => { + const testManager = new Earwurm(); + + it('is initialized with default values', () => { + expect(testManager).toBeInstanceOf(Earwurm); + + // Class static properties + expect(Earwurm).toHaveProperty('maxStackSize', tokens.maxStackSize); + expect(Earwurm).toHaveProperty('suspendAfterMs', tokens.suspendAfterMs); + + // Instance properties + expect(testManager).toHaveProperty('volume', 1); + expect(testManager).toHaveProperty('mute', false); + expect(testManager).toHaveProperty('unlocked', false); + expect(testManager).toHaveProperty('keys', []); + expect(testManager).toHaveProperty('state', 'suspended'); + expect(testManager).toHaveProperty('playing', false); + }); + }); + + // `mute` accessor is covered in `Abstract.test.ts`. + // describe('mute', () => {}); + + // `volume` accessor is covered in `Abstract.test.ts`. + // describe('volume', () => {}); + + // `unlocked` getter is covered in `unlock()` test. + // describe('unlocked', () => {}); + + describe('keys', () => { + it('contains ids of each active Stack', async () => { + const testManager = new Earwurm(); + + expect(testManager.keys).toHaveLength(0); + + testManager.add( + {id: 'One', path: mockData.audio}, + {id: 'Two', path: 'to/no/file.mp3'}, + {id: 'Three', path: ''}, + ); + + expect(testManager.keys).toStrictEqual(['One', 'Two', 'Three']); + testManager.remove('One'); + expect(testManager.keys).toStrictEqual(['Two', 'Three']); + }); + }); + + describe('state', () => { + it('triggers `statechange` event for every state', async () => { + const testManager = new Earwurm(); + const spyState: ManagerEventMap['statechange'] = vi.fn((_state) => {}); + + testManager.on('statechange', spyState); + + expect(spyState).not.toBeCalled(); + expect(testManager.state).toBe('suspended'); + + testManager.unlock(); + + const clickEvent = new Event('click'); + document.dispatchEvent(clickEvent); + + // await vi.advanceTimersToNextTimerAsync(); + + expect(spyState).toBeCalledTimes(1); + expect(spyState).toBeCalledWith('running'); + + testManager.stop(); + + // `suspending/suspended` are called in rapid succession. + expect(spyState).toBeCalledTimes(3); + expect(spyState).toBeCalledWith('suspending'); + expect(spyState).toBeCalledWith('suspended'); + + testManager.teardown(); + + expect(spyState).toBeCalledTimes(4); + expect(spyState).toBeCalledWith('closed'); + + // TODO: Find a way to mock an "interruption" so we + // can test the `interrupted` state change. + expect(spyState).not.toBeCalledTimes(5); + }); + }); + + describe('playing', () => { + it('is `true` when any Sound is `playing`', async () => { + const testManager = new Earwurm(); + + testManager.add({id: 'One', path: mockData.audio}); + + const stack = testManager.get('One'); + const sound = await stack?.prepare(); + + expect(testManager.playing).toBe(false); + sound?.play(); + expect(testManager.playing).toBe(true); + vi.advanceTimersByTime(10); + expect(testManager.playing).toBe(false); + }); }); }); diff --git a/src/tests/Sound.test.ts b/src/tests/Sound.test.ts index e62f9b9..0bebf4a 100644 --- a/src/tests/Sound.test.ts +++ b/src/tests/Sound.test.ts @@ -16,7 +16,7 @@ describe('Sound component', () => { const defaultAudioNode = new AudioNode(); describe('initialization', () => { - const mockSound = new Sound( + const testSound = new Sound( 'TestInit', defaultAudioBuffer, defaultContext, @@ -24,13 +24,13 @@ describe('Sound component', () => { ); it('is initialized with default values', () => { - expect(mockSound).toBeInstanceOf(Sound); + expect(testSound).toBeInstanceOf(Sound); - expect(mockSound).toHaveProperty('volume', 1); - expect(mockSound).toHaveProperty('mute', false); - expect(mockSound).toHaveProperty('loop', false); - expect(mockSound).toHaveProperty('duration', 0); - expect(mockSound).toHaveProperty('state', 'created'); + expect(testSound).toHaveProperty('volume', 1); + expect(testSound).toHaveProperty('mute', false); + expect(testSound).toHaveProperty('loop', false); + expect(testSound).toHaveProperty('duration', 0); + expect(testSound).toHaveProperty('state', 'created'); }); }); @@ -41,7 +41,7 @@ describe('Sound component', () => { // describe('volume', () => {}); describe('loop', () => { - const mockSound = new Sound( + const testSound = new Sound( 'TestLoop', defaultAudioBuffer, defaultContext, @@ -50,14 +50,14 @@ describe('Sound component', () => { it('allows `set` and `get`', () => { // TODO: Should check `AudioBufferSourceNode` value. - expect(mockSound.loop).toBe(false); - mockSound.loop = true; - expect(mockSound.loop).toBe(true); + expect(testSound.loop).toBe(false); + testSound.loop = true; + expect(testSound.loop).toBe(true); }); }); describe('duration', () => { - const mockSound = new Sound( + const testSound = new Sound( 'TestDuration', defaultAudioBuffer, defaultContext, @@ -66,7 +66,7 @@ describe('Sound component', () => { it('allows `get`', () => { // TODO: We should provide a buffer that has a `duration`. - expect(mockSound.duration).toBe(0); + expect(testSound.duration).toBe(0); }); }); @@ -82,26 +82,47 @@ describe('Sound component', () => { ]; it('starts playing the source', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); const spySourceStart = vi.spyOn(AudioBufferSourceNode.prototype, 'start'); expect(spySourceStart).not.toBeCalled(); - mockSound.play(); + testSound.play(); expect(spySourceStart).toBeCalledTimes(1); }); + it('does not call `start()` a 2nd time', () => { + const testSound = new Sound(...mockConstructorArgs); + const spySourceStart = vi.spyOn(AudioBufferSourceNode.prototype, 'start'); + + testSound.play().play().play(); + expect(spySourceStart).toBeCalledTimes(1); + }); + + it('unpauses the source', () => { + const testSound = new Sound(...mockConstructorArgs); + + // TODO: Figure out how to check `#source` for `playbackRate.value`. + expect(testSound.mute).toBe(false); + testSound.play(); + expect(testSound.mute).toBe(false); + testSound.pause(); + expect(testSound.mute).toBe(true); + testSound.play(); + expect(testSound.mute).toBe(false); + }); + it('updates state', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); - mockSound.play(); - expect(mockSound.state).toBe('playing'); + testSound.play(); + expect(testSound.state).toBe('playing'); }); it('returns instance', () => { - const mockSound = new Sound(...mockConstructorArgs); - const instance = mockSound.play(); + const testSound = new Sound(...mockConstructorArgs); + const instance = testSound.play(); - expect(instance).toBe(mockSound); + expect(instance).toBe(testSound); }); }); @@ -113,21 +134,31 @@ describe('Sound component', () => { defaultAudioNode, ]; - // TODO: Figure out how to check `#source` for `playbackRate.value`. - it.todo('pauses the source'); + it('pauses the source', () => { + const testSound = new Sound(...mockConstructorArgs); + + // TODO: Figure out how to check `#source` for `playbackRate.value`. + testSound.play(); + expect(testSound.mute).toBe(false); + testSound.pause(); + expect(testSound.mute).toBe(true); + }); it('updates state', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); - mockSound.play().pause(); - expect(mockSound.state).toBe('paused'); + testSound.play().pause(); + expect(testSound.state).toBe('paused'); }); + // This condition is already covered in `events`. + // it('does not update state again if already paused'); + it('returns instance', () => { - const mockSound = new Sound(...mockConstructorArgs); - const instance = mockSound.play().pause(); + const testSound = new Sound(...mockConstructorArgs); + const instance = testSound.play().pause(); - expect(instance).toBe(mockSound); + expect(instance).toBe(testSound); }); }); @@ -140,45 +171,45 @@ describe('Sound component', () => { ]; it('stops and disconnects the source', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); const spySourceStop = vi.spyOn(AudioBufferSourceNode.prototype, 'stop'); const spySourceDisconnect = vi.spyOn( AudioBufferSourceNode.prototype, 'disconnect', ); - mockSound.play(); + testSound.play(); expect(spySourceStop).not.toBeCalled(); expect(spySourceDisconnect).not.toBeCalled(); - mockSound.stop(); + testSound.stop(); expect(spySourceStop).toBeCalledTimes(1); expect(spySourceDisconnect).toBeCalledTimes(1); }); it('updates state', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); - mockSound.play().stop(); - expect(mockSound.state).toBe('stopping'); + testSound.play().stop(); + expect(testSound.state).toBe('stopping'); }); it('empties active events', () => { - const mockSound = new Sound(...mockConstructorArgs); - mockSound.on('ended', vi.fn()); + const testSound = new Sound(...mockConstructorArgs); + testSound.on('ended', vi.fn()); // Unable to spy on private `empty()` method, so we // check against `activeEvents` instead. - expect(mockSound.activeEvents).toHaveLength(1); - mockSound.play().stop(); - expect(mockSound.activeEvents).toHaveLength(0); + expect(testSound.activeEvents).toHaveLength(1); + testSound.play().stop(); + expect(testSound.activeEvents).toHaveLength(0); }); it('returns instance', () => { - const mockSound = new Sound(...mockConstructorArgs); - const instance = mockSound.play().stop(); + const testSound = new Sound(...mockConstructorArgs); + const instance = testSound.play().stop(); - expect(instance).toBe(mockSound); + expect(instance).toBe(testSound); }); }); @@ -191,37 +222,57 @@ describe('Sound component', () => { ]; it('emits an event for every statechange', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); const spyStateChange: SoundEventMap['statechange'] = vi.fn( (_state) => {}, ); - mockSound.on('statechange', spyStateChange); + testSound.on('statechange', spyStateChange); expect(spyStateChange).not.toBeCalled(); - mockSound.play(); + testSound.play(); expect(spyStateChange).toBeCalledWith('playing'); - mockSound.pause(); + testSound.pause(); expect(spyStateChange).toBeCalledWith('paused'); - mockSound.stop(); + testSound.stop(); expect(spyStateChange).toBeCalledWith('stopping'); }); + it('does not emit redundant state changes', () => { + const testSound = new Sound(...mockConstructorArgs); + const spyStateChange: SoundEventMap['statechange'] = vi.fn( + (_state) => {}, + ); + + testSound.on('statechange', spyStateChange); + + testSound.play(); + expect(spyStateChange).toBeCalledTimes(1); + expect(spyStateChange).toBeCalledWith('playing'); + + testSound.pause(); + expect(spyStateChange).toBeCalledTimes(2); + expect(spyStateChange).toBeCalledWith('paused'); + + testSound.pause(); + expect(spyStateChange).not.toBeCalledTimes(3); + }); + it('emits `ended` event once sound has finished', () => { - const mockSound = new Sound(...mockConstructorArgs); + const testSound = new Sound(...mockConstructorArgs); const spyEnded: SoundEventMap['ended'] = vi.fn((_event) => {}); - mockSound.on('ended', spyEnded); - mockSound.play(); + testSound.on('ended', spyEnded); + testSound.play(); expect(spyEnded).not.toBeCalled(); vi.advanceTimersToNextTimer(); expect(spyEnded).toBeCalledWith({ - id: mockSound.id, + id: testSound.id, source: expect.any(AudioBufferSourceNode), }); }); diff --git a/src/tests/Stack.test.ts b/src/tests/Stack.test.ts index 26cb658..e0d2801 100644 --- a/src/tests/Stack.test.ts +++ b/src/tests/Stack.test.ts @@ -13,7 +13,7 @@ describe('Stack component', () => { const defaultAudioNode = new AudioNode(); describe('initialization', () => { - const mockStack = new Stack( + const testStack = new Stack( 'TestInit', mockData.audio, defaultContext, @@ -21,17 +21,17 @@ describe('Stack component', () => { ); it('is initialized with default values', () => { - expect(mockStack).toBeInstanceOf(Stack); + expect(testStack).toBeInstanceOf(Stack); // Class static properties expect(Stack).toHaveProperty('maxStackSize', tokens.maxStackSize); // Instance properties - expect(mockStack).toHaveProperty('volume', 1); - expect(mockStack).toHaveProperty('mute', false); - expect(mockStack).toHaveProperty('keys', []); - expect(mockStack).toHaveProperty('state', 'idle'); - expect(mockStack).toHaveProperty('playing', false); + expect(testStack).toHaveProperty('volume', 1); + expect(testStack).toHaveProperty('mute', false); + expect(testStack).toHaveProperty('keys', []); + expect(testStack).toHaveProperty('state', 'idle'); + expect(testStack).toHaveProperty('playing', false); }); }); @@ -50,11 +50,11 @@ describe('Stack component', () => { ]; it('contains ids of each unexpired Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - const sound1 = await mockStack.prepare('One'); - const sound2 = await mockStack.prepare('Two'); - const sound3 = await mockStack.prepare('Three'); + const sound1 = await testStack.prepare('One'); + const sound2 = await testStack.prepare('Two'); + const sound3 = await testStack.prepare('Three'); sound1.play(); vi.advanceTimersByTime(4); @@ -62,13 +62,13 @@ describe('Stack component', () => { vi.advanceTimersByTime(4); sound3.play(); - expect(mockStack.keys).toStrictEqual(['One', 'Two', 'Three']); + expect(testStack.keys).toStrictEqual(['One', 'Two', 'Three']); vi.advanceTimersByTime(2); - expect(mockStack.keys).toStrictEqual(['Two', 'Three']); + expect(testStack.keys).toStrictEqual(['Two', 'Three']); vi.advanceTimersByTime(4); - expect(mockStack.keys).toStrictEqual(['Three']); + expect(testStack.keys).toStrictEqual(['Three']); vi.advanceTimersByTime(4); - expect(mockStack.keys).toStrictEqual([]); + expect(testStack.keys).toStrictEqual([]); }); }); @@ -81,15 +81,15 @@ describe('Stack component', () => { ]; it('triggers `statechange` event for every state', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); const spyState: StackEventMap['statechange'] = vi.fn((_state) => {}); - mockStack.on('statechange', spyState); + testStack.on('statechange', spyState); expect(spyState).not.toBeCalled(); - expect(mockStack.state).toBe('idle'); + expect(testStack.state).toBe('idle'); - const soundFoo = mockStack.prepare('Foo'); + const soundFoo = testStack.prepare('Foo'); expect(spyState).toBeCalledWith('loading'); await soundFoo.then((sound) => sound.play()); @@ -110,14 +110,21 @@ describe('Stack component', () => { ]; it('is `true` when any Sound is `playing`', async () => { - const mockStack = new Stack(...mockConstructorArgs); - const sound = await mockStack.prepare(); + const testStack = new Stack(...mockConstructorArgs); + const sound = await testStack.prepare(); + + expect(testStack.state).toBe('idle'); + expect(testStack.playing).toBe(false); - expect(mockStack.playing).toBe(false); sound.play(); - expect(mockStack.playing).toBe(true); + + expect(testStack.state).toBe('playing'); + expect(testStack.playing).toBe(true); + vi.advanceTimersByTime(10); - expect(mockStack.playing).toBe(false); + + expect(testStack.state).toBe('idle'); + expect(testStack.playing).toBe(false); }); }); @@ -130,19 +137,19 @@ describe('Stack component', () => { ]; it('returns `undefined` when there is no matching Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); - await mockStack.prepare('RealId'); + const testStack = new Stack(...mockConstructorArgs); + await testStack.prepare('RealId'); - const requestedSound = mockStack.get('FakeId'); + const requestedSound = testStack.get('FakeId'); expect(requestedSound).toBe(undefined); }); it('returns the requested Sound when present', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); const mockSoundId = 'RealId'; - const capturedSound = await mockStack.prepare(mockSoundId); - const requestedSound = mockStack.get(mockSoundId); + const capturedSound = await testStack.prepare(mockSoundId); + const requestedSound = testStack.get(mockSoundId); expect(requestedSound).toBeInstanceOf(Sound); expect(requestedSound).toBe(capturedSound); @@ -158,17 +165,17 @@ describe('Stack component', () => { ]; it('returns `false` when there is no matching Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); - const hasSound = mockStack.has('FakeId'); + const testStack = new Stack(...mockConstructorArgs); + const hasSound = testStack.has('FakeId'); expect(hasSound).toBe(false); }); it('returns `true` when the requested Sound is present', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); const mockSoundId = 'RealId'; - await mockStack.prepare(mockSoundId); - const hasSound = mockStack.has(mockSoundId); + await testStack.prepare(mockSoundId); + const hasSound = testStack.has(mockSoundId); expect(hasSound).toBe(true); }); @@ -183,27 +190,27 @@ describe('Stack component', () => { ]; it('pauses and retains every Sound within the Stack', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - const sound1 = await mockStack.prepare('One'); - const sound2 = await mockStack.prepare('Two'); - const sound3 = await mockStack.prepare('Three'); + const sound1 = await testStack.prepare('One'); + const sound2 = await testStack.prepare('Two'); + const sound3 = await testStack.prepare('Three'); const spySound1Pause = vi.spyOn(sound1, 'pause'); const spySound2Pause = vi.spyOn(sound2, 'pause'); const spySound3Pause = vi.spyOn(sound3, 'pause'); - expect(mockStack.state).toBe('idle'); - expect(mockStack.keys).toHaveLength(3); + expect(testStack.state).toBe('idle'); + expect(testStack.keys).toHaveLength(3); sound1.play(); sound2.play(); sound3.play(); - expect(mockStack.state).toBe('playing'); - mockStack.pause(); - expect(mockStack.state).toBe('idle'); - expect(mockStack.keys).toHaveLength(3); + expect(testStack.state).toBe('playing'); + testStack.pause(); + expect(testStack.state).toBe('idle'); + expect(testStack.keys).toHaveLength(3); expect(spySound1Pause).toBeCalled(); expect(spySound2Pause).toBeCalled(); @@ -211,11 +218,11 @@ describe('Stack component', () => { }); it('returns instance', async () => { - const mockStack = new Stack(...mockConstructorArgs); - await mockStack.prepare('Foo'); - const instance = mockStack.pause(); + const testStack = new Stack(...mockConstructorArgs); + await testStack.prepare('Foo'); + const instance = testStack.pause(); - expect(instance).toBe(mockStack); + expect(instance).toBe(testStack); }); }); @@ -228,27 +235,27 @@ describe('Stack component', () => { ]; it('stops and removes every Sound within the Stack', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - const sound1 = await mockStack.prepare('One'); - const sound2 = await mockStack.prepare('Two'); - const sound3 = await mockStack.prepare('Three'); + const sound1 = await testStack.prepare('One'); + const sound2 = await testStack.prepare('Two'); + const sound3 = await testStack.prepare('Three'); const spySound1Stop = vi.spyOn(sound1, 'stop'); const spySound2Stop = vi.spyOn(sound2, 'stop'); const spySound3Stop = vi.spyOn(sound3, 'stop'); - expect(mockStack.state).toBe('idle'); - expect(mockStack.keys).toHaveLength(3); + expect(testStack.state).toBe('idle'); + expect(testStack.keys).toHaveLength(3); sound1.play(); sound2.play(); sound3.play(); - expect(mockStack.state).toBe('playing'); - mockStack.stop(); - expect(mockStack.state).toBe('idle'); - expect(mockStack.keys).toHaveLength(0); + expect(testStack.state).toBe('playing'); + testStack.stop(); + expect(testStack.state).toBe('idle'); + expect(testStack.keys).toHaveLength(0); expect(spySound1Stop).toBeCalled(); expect(spySound2Stop).toBeCalled(); @@ -256,11 +263,11 @@ describe('Stack component', () => { }); it('returns instance', async () => { - const mockStack = new Stack(...mockConstructorArgs); - await mockStack.prepare('Foo'); - const instance = mockStack.stop(); + const testStack = new Stack(...mockConstructorArgs); + await testStack.prepare('Foo'); + const instance = testStack.stop(); - expect(instance).toBe(mockStack); + expect(instance).toBe(testStack); }); }); @@ -273,31 +280,31 @@ describe('Stack component', () => { ]; it('calls stop()', async () => { - const mockStack = new Stack(...mockConstructorArgs); - const spyStop = vi.spyOn(mockStack, 'stop'); + const testStack = new Stack(...mockConstructorArgs); + const spyStop = vi.spyOn(testStack, 'stop'); expect(spyStop).not.toBeCalled(); - mockStack.teardown(); + testStack.teardown(); expect(spyStop).toBeCalledTimes(1); }); it('empties all active events', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - mockStack.on('error', vi.fn()); - mockStack.on('statechange', vi.fn()); + testStack.on('error', vi.fn()); + testStack.on('statechange', vi.fn()); - expect(mockStack.activeEvents).toHaveLength(2); - mockStack.teardown(); - expect(mockStack.activeEvents).toHaveLength(0); + expect(testStack.activeEvents).toHaveLength(2); + testStack.teardown(); + expect(testStack.activeEvents).toHaveLength(0); }); it('returns instance', async () => { - const mockStack = new Stack(...mockConstructorArgs); - await mockStack.prepare('Foo'); - const instance = mockStack.stop(); + const testStack = new Stack(...mockConstructorArgs); + await testStack.prepare('Foo'); + const instance = testStack.stop(); - expect(instance).toBe(mockStack); + expect(instance).toBe(testStack); }); }); @@ -311,13 +318,13 @@ describe('Stack component', () => { ]; it('auto-assigns an id when none is provided', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - await mockStack.prepare(); - await mockStack.prepare(); - await mockStack.prepare(); + await testStack.prepare(); + await testStack.prepare(); + await testStack.prepare(); - expect(mockStack.keys).toStrictEqual([ + expect(testStack.keys).toStrictEqual([ `${mockStackId}-1`, `${mockStackId}-2`, `${mockStackId}-3`, @@ -327,14 +334,14 @@ describe('Stack component', () => { it('assigns the provided id and continues to increment internal counter', async () => { const mockSoundId1 = 'Foo'; const mockSoundId3 = 'Bar'; - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - await mockStack.prepare(mockSoundId1); - await mockStack.prepare(); - await mockStack.prepare(mockSoundId3); - await mockStack.prepare(); + await testStack.prepare(mockSoundId1); + await testStack.prepare(); + await testStack.prepare(mockSoundId3); + await testStack.prepare(); - expect(mockStack.keys).toStrictEqual([ + expect(testStack.keys).toStrictEqual([ mockSoundId1, `${mockStackId}-2`, mockSoundId3, @@ -343,9 +350,9 @@ describe('Stack component', () => { }); it('returns a Promise containing the newly created Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); const mockSoundId = 'Foo'; - const sound = mockStack.prepare(mockSoundId); + const sound = testStack.prepare(mockSoundId); expect(sound).toBeInstanceOf(Promise); await expect(sound).resolves.toBeInstanceOf(Sound); @@ -362,33 +369,33 @@ describe('Stack component', () => { ]; it('sets state to `loading` until fetch is resolved', async () => { - const mockStack = new Stack(...mockConstructorArgs); - const sound = mockStack.prepare(); + const testStack = new Stack(...mockConstructorArgs); + const sound = testStack.prepare(); - expect(mockStack.state).toBe('loading'); + expect(testStack.state).toBe('loading'); await sound; - expect(mockStack.state).toBe('idle'); + expect(testStack.state).toBe('idle'); }); it('returns state to `playing` if a Sound was already playing', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); - expect(mockStack.state).toBe('idle'); - await mockStack.prepare().then((sound) => sound.play()); - expect(mockStack.state).toBe('playing'); + expect(testStack.state).toBe('idle'); + await testStack.prepare().then((sound) => sound.play()); + expect(testStack.state).toBe('playing'); - const unplayedSound = mockStack.prepare(); + const unplayedSound = testStack.prepare(); - expect(mockStack.state).toBe('loading'); + expect(testStack.state).toBe('loading'); await unplayedSound; - expect(mockStack.state).toBe('playing'); + expect(testStack.state).toBe('playing'); }); it('emits error when encountered', async () => { const mockStackId = 'TestLoadFail'; const mockPath = 'fake/path/file.mp3'; - const mockStack = new Stack( + const testStack = new Stack( mockStackId, mockPath, defaultContext, @@ -397,8 +404,8 @@ describe('Stack component', () => { const spyError: StackEventMap['error'] = vi.fn((_error) => {}); - mockStack.on('error', spyError); - await mockStack.prepare(); + testStack.on('error', spyError); + await testStack.prepare(); expect(spyError).toBeCalledWith({ id: mockStackId, @@ -410,14 +417,14 @@ describe('Stack component', () => { }); it('returns scratchBuffer on error', async () => { - const mockStack = new Stack( + const testStack = new Stack( 'TestLoadScratch', 'path/to/no/where.webm', defaultContext, defaultAudioNode, ); - const scratchSound = await mockStack.prepare(); + const scratchSound = await testStack.prepare(); const scratchBuffer = scratchSound.buffer; expect(scratchBuffer).toBeInstanceOf(AudioBuffer); @@ -447,10 +454,10 @@ describe('Stack component', () => { } it('constructs Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); const mockSoundId = 'Foo'; - const sound = await mockStack.prepare(mockSoundId); + const sound = await testStack.prepare(mockSoundId); expect(sound).toHaveProperty('id', mockSoundId); expect(sound).toHaveProperty('buffer', { @@ -469,16 +476,16 @@ describe('Stack component', () => { }); it('registers `statechange` multi-listener on Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); - const sound = await mockStack.prepare(); + const testStack = new Stack(...mockConstructorArgs); + const sound = await testStack.prepare(); sound.play().pause(); expect(sound.activeEvents).toContain('statechange'); }); it('registers `ended` single-listener on Sound', async () => { - const mockStack = new Stack(...mockConstructorArgs); - const sound = await mockStack.prepare(); + const testStack = new Stack(...mockConstructorArgs); + const sound = await testStack.prepare(); sound.play(); // No way to really check that the event is removed, @@ -487,12 +494,12 @@ describe('Stack component', () => { }); it('expires old sounds that fall outside of the `maxStackSize`', async () => { - const mockStack = new Stack(...mockConstructorArgs); + const testStack = new Stack(...mockConstructorArgs); const spyEnded: SoundEventMap['ended'] = vi.fn((_ended) => {}); // Fill the `queue` up with the exact max number of Sounds. const pendingSounds = arrayOfLength(Stack.maxStackSize).map( - async () => await mockStack.prepare(), + async () => await testStack.prepare(), ); const sounds = await Promise.all(pendingSounds); @@ -507,7 +514,7 @@ describe('Stack component', () => { // Order will be different because of `async` Sound creation, // but `strictEqual` comparison doesn't care about order. - expect(mockStack.keys).toStrictEqual([ + expect(testStack.keys).toStrictEqual([ `${mockStackId}-1`, `${mockStackId}-2`, `${mockStackId}-3`, @@ -521,12 +528,12 @@ describe('Stack component', () => { // Add more sounds before any current Sound has finished playing. const additionalSounds = arrayOfLength(additionalSoundsCount).map( - async () => await mockStack.prepare(), + async () => await testStack.prepare(), ); await Promise.all(additionalSounds); - expect(mockStack.keys).toHaveLength(Stack.maxStackSize); + expect(testStack.keys).toHaveLength(Stack.maxStackSize); expect(spyEnded).toBeCalledTimes(additionalSoundsCount); }); }); diff --git a/src/tests/mock/MockAudioContext.ts b/src/tests/mock/MockAudioContext.ts index 65f85a7..d224d71 100644 --- a/src/tests/mock/MockAudioContext.ts +++ b/src/tests/mock/MockAudioContext.ts @@ -40,14 +40,35 @@ export class MockAudioContext } async close(): Promise { - throw new Error(internalMessage('close')); + // Setting + dispatching state outside of the + // Promise to avoid complicated async testing. + this.state = 'closed'; + this.dispatchEvent(new Event('statechange')); + + await new Promise((resolve) => { + resolve(() => {}); + }); } async resume(): Promise { - throw new Error(internalMessage('resume')); + // Setting + dispatching state outside of the + // Promise to avoid complicated async testing. + this.state = 'running'; + this.dispatchEvent(new Event('statechange')); + + await new Promise((resolve) => { + resolve(() => {}); + }); } async suspend(): Promise { - throw new Error(internalMessage('suspend')); + // Setting + dispatching state outside of the + // Promise to avoid complicated async testing. + this.state = 'suspended'; + this.dispatchEvent(new Event('statechange')); + + await new Promise((resolve) => { + resolve(() => {}); + }); } } diff --git a/src/tests/mock/MockBaseAudioContext.ts b/src/tests/mock/MockBaseAudioContext.ts index 1d455af..3b3d5d9 100644 --- a/src/tests/mock/MockBaseAudioContext.ts +++ b/src/tests/mock/MockBaseAudioContext.ts @@ -18,11 +18,14 @@ export class MockBaseAudioContext // to update this value from within it’s constructor. sampleRate = 44100; + // Cannot be `readonly` becauase we need to mock + // updating `state` in various methods. + state: AudioContextState = 'suspended'; + readonly currentTime = 0; readonly audioWorklet = new MockAudioWorklet(); readonly destination = new MockAudioDestinationNode(); readonly listener = new MockAudioListener(); - readonly state: AudioContextState = 'suspended'; readonly onstatechange: AudioContext['onstatechange'] = null; createAnalyser(): AnalyserNode { diff --git a/src/tokens.ts b/src/tokens.ts index bcfe605..061c903 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1,4 +1,6 @@ export const tokens = { maxStackSize: 8, suspendAfterMs: 30000, + // Browser's don't seem to accept a `0` value. + minPlaybackRate: 0.0001, }; diff --git a/vite.config.ts b/vite.config.ts index 1dc2b50..5a3f9b1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -17,7 +17,15 @@ export default defineConfig({ }, minify: false, }, - plugins: [dts()], + plugins: [ + dts({ + // Will capture only the types that are exposed to consumers + // and condense them all into a single file. If we also want + // unexported types, as well as their folder structure, then + // replace `rollupTypes` with `insertTypesEntry`. + rollupTypes: true, + }), + ], test: { environment: 'happy-dom', setupFiles: 'config/tests-setup',