diff --git a/package-lock.json b/package-lock.json index 23570df0..476d9233 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "jszip": "^3.10.1", "moment-of-symmetry": "^0.3.2", + "pinia": "^2.1.7", "qs": "^6.11.0", "scale-workshop-core": "github:xenharmonic-devs/scale-workshop-core#v0.0.6", "temperaments": "^0.4.5", @@ -43,9 +44,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.20.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.3.tgz", - "integrity": "sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "bin": { "parser": "bin/babel-parser.js" }, @@ -202,6 +203,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -605,55 +611,55 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.45.tgz", - "integrity": "sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.9.tgz", + "integrity": "sha512-+/Lf68Vr/nFBA6ol4xOtJrW+BQWv3QWKfRwGSm70jtXwfhZNF4R/eRgyVJYoxFRhdCTk/F6g99BP0ffPgZihfQ==", "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/shared": "3.2.45", + "@babel/parser": "^7.23.3", + "@vue/shared": "3.3.9", "estree-walker": "^2.0.2", - "source-map": "^0.6.1" + "source-map-js": "^1.0.2" } }, "node_modules/@vue/compiler-dom": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.45.tgz", - "integrity": "sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.9.tgz", + "integrity": "sha512-nfWubTtLXuT4iBeDSZ5J3m218MjOy42Vp2pmKVuBKo2/BLcrFUX8nCSr/bKRFiJ32R8qbdnnnBgRn9AdU5v0Sg==", "dependencies": { - "@vue/compiler-core": "3.2.45", - "@vue/shared": "3.2.45" + "@vue/compiler-core": "3.3.9", + "@vue/shared": "3.3.9" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.45.tgz", - "integrity": "sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q==", - "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.45", - "@vue/compiler-dom": "3.2.45", - "@vue/compiler-ssr": "3.2.45", - "@vue/reactivity-transform": "3.2.45", - "@vue/shared": "3.2.45", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.9.tgz", + "integrity": "sha512-wy0CNc8z4ihoDzjASCOCsQuzW0A/HP27+0MDSSICMjVIFzk/rFViezkR3dzH+miS2NDEz8ywMdbjO5ylhOLI2A==", + "dependencies": { + "@babel/parser": "^7.23.3", + "@vue/compiler-core": "3.3.9", + "@vue/compiler-dom": "3.3.9", + "@vue/compiler-ssr": "3.3.9", + "@vue/reactivity-transform": "3.3.9", + "@vue/shared": "3.3.9", "estree-walker": "^2.0.2", - "magic-string": "^0.25.7", - "postcss": "^8.1.10", - "source-map": "^0.6.1" + "magic-string": "^0.30.5", + "postcss": "^8.4.31", + "source-map-js": "^1.0.2" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.45.tgz", - "integrity": "sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.9.tgz", + "integrity": "sha512-NO5oobAw78R0G4SODY5A502MGnDNiDjf6qvhn7zD7TJGc8XDeIEw4fg6JU705jZ/YhuokBKz0A5a/FL/XZU73g==", "dependencies": { - "@vue/compiler-dom": "3.2.45", - "@vue/shared": "3.2.45" + "@vue/compiler-dom": "3.3.9", + "@vue/shared": "3.3.9" } }, "node_modules/@vue/devtools-api": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.4.5.tgz", - "integrity": "sha512-JD5fcdIuFxU4fQyXUu3w2KpAJHzTVdN+p4iOX2lMWSHMOoQdMAcpFLZzm9Z/2nmsoZ1a96QEhZ26e50xLBsgOQ==" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.1.tgz", + "integrity": "sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==" }, "node_modules/@vue/eslint-config-prettier": { "version": "7.0.0", @@ -688,60 +694,60 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.45.tgz", - "integrity": "sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.9.tgz", + "integrity": "sha512-VmpIqlNp+aYDg2X0xQhJqHx9YguOmz2UxuUJDckBdQCNkipJvfk9yA75woLWElCa0Jtyec3lAAt49GO0izsphw==", "dependencies": { - "@vue/shared": "3.2.45" + "@vue/shared": "3.3.9" } }, "node_modules/@vue/reactivity-transform": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.45.tgz", - "integrity": "sha512-BHVmzYAvM7vcU5WmuYqXpwaBHjsS8T63jlKGWVtHxAHIoMIlmaMyurUSEs1Zcg46M4AYT5MtB1U274/2aNzjJQ==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.9.tgz", + "integrity": "sha512-HnUFm7Ry6dFa4Lp63DAxTixUp8opMtQr6RxQCpDI1vlh12rkGIeYqMvJtK+IKyEfEOa2I9oCkD1mmsPdaGpdVg==", "dependencies": { - "@babel/parser": "^7.16.4", - "@vue/compiler-core": "3.2.45", - "@vue/shared": "3.2.45", + "@babel/parser": "^7.23.3", + "@vue/compiler-core": "3.3.9", + "@vue/shared": "3.3.9", "estree-walker": "^2.0.2", - "magic-string": "^0.25.7" + "magic-string": "^0.30.5" } }, "node_modules/@vue/runtime-core": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.45.tgz", - "integrity": "sha512-gzJiTA3f74cgARptqzYswmoQx0fIA+gGYBfokYVhF8YSXjWTUA2SngRzZRku2HbGbjzB6LBYSbKGIaK8IW+s0A==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.9.tgz", + "integrity": "sha512-xxaG9KvPm3GTRuM4ZyU8Tc+pMVzcu6eeoSRQJ9IE7NmCcClW6z4B3Ij6L4EDl80sxe/arTtQ6YmgiO4UZqRc+w==", "dependencies": { - "@vue/reactivity": "3.2.45", - "@vue/shared": "3.2.45" + "@vue/reactivity": "3.3.9", + "@vue/shared": "3.3.9" } }, "node_modules/@vue/runtime-dom": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.45.tgz", - "integrity": "sha512-cy88YpfP5Ue2bDBbj75Cb4bIEZUMM/mAkDMfqDTpUYVgTf/kuQ2VQ8LebuZ8k6EudgH8pYhsGWHlY0lcxlvTwA==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.9.tgz", + "integrity": "sha512-e7LIfcxYSWbV6BK1wQv9qJyxprC75EvSqF/kQKe6bdZEDNValzeRXEVgiX7AHI6hZ59HA4h7WT5CGvm69vzJTQ==", "dependencies": { - "@vue/runtime-core": "3.2.45", - "@vue/shared": "3.2.45", - "csstype": "^2.6.8" + "@vue/runtime-core": "3.3.9", + "@vue/shared": "3.3.9", + "csstype": "^3.1.2" } }, "node_modules/@vue/server-renderer": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.45.tgz", - "integrity": "sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g==", + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.9.tgz", + "integrity": "sha512-w0zT/s5l3Oa3ZjtLW88eO4uV6AQFqU8X5GOgzq7SkQQu6vVr+8tfm+OI2kDBplS/W/XgCBuFXiPw6T5EdwXP0A==", "dependencies": { - "@vue/compiler-ssr": "3.2.45", - "@vue/shared": "3.2.45" + "@vue/compiler-ssr": "3.3.9", + "@vue/shared": "3.3.9" }, "peerDependencies": { - "vue": "3.2.45" + "vue": "3.3.9" } }, "node_modules/@vue/shared": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz", - "integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==" + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.9.tgz", + "integrity": "sha512-ZE0VTIR0LmYgeyhurPTpy4KzKsuDyQbMSdM49eKkMnT5X4VfFBLysMzjIZhLEFQYjjOVVfbvUDHckwjDFiO2eA==" }, "node_modules/@vue/test-utils": { "version": "2.2.3", @@ -1444,9 +1450,9 @@ "dev": true }, "node_modules/csstype": { - "version": "2.6.21", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, "node_modules/cypress": { "version": "9.7.0", @@ -3353,11 +3359,14 @@ } }, "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", "dependencies": { - "sourcemap-codec": "^1.4.8" + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" } }, "node_modules/map-stream": { @@ -3482,9 +3491,15 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3755,10 +3770,60 @@ "node": ">=0.10.0" } }, + "node_modules/pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "dependencies": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/postcss": { - "version": "8.4.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", - "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -3767,10 +3832,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -4236,6 +4305,8 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, "engines": { "node": ">=0.10.0" } @@ -4248,11 +4319,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" - }, "node_modules/split": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", @@ -4674,7 +4740,7 @@ "version": "4.6.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4837,15 +4903,23 @@ } }, "node_modules/vue": { - "version": "3.2.45", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.2.45.tgz", - "integrity": "sha512-9Nx/Mg2b2xWlXykmCwiTUCWHbWIj53bnkizBxKai1g61f2Xit700A1ljowpTIM11e3uipOeiPcSqnmBg6gyiaA==", - "dependencies": { - "@vue/compiler-dom": "3.2.45", - "@vue/compiler-sfc": "3.2.45", - "@vue/runtime-dom": "3.2.45", - "@vue/server-renderer": "3.2.45", - "@vue/shared": "3.2.45" + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.9.tgz", + "integrity": "sha512-sy5sLCTR8m6tvUk1/ijri3Yqzgpdsmxgj6n6yl7GXXCXqVbmW2RCXe9atE4cEI6Iv7L89v5f35fZRRr5dChP9w==", + "dependencies": { + "@vue/compiler-dom": "3.3.9", + "@vue/compiler-sfc": "3.3.9", + "@vue/runtime-dom": "3.3.9", + "@vue/server-renderer": "3.3.9", + "@vue/shared": "3.3.9" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/vue-eslint-parser": { diff --git a/package.json b/package.json index 93864a3c..e9cdbd5d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "jszip": "^3.10.1", "moment-of-symmetry": "^0.3.2", + "pinia": "^2.1.7", "qs": "^6.11.0", "scale-workshop-core": "github:xenharmonic-devs/scale-workshop-core#v0.0.6", "temperaments": "^0.4.5", diff --git a/src/App.vue b/src/App.vue index 5edbc410..91b73090 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,8 +26,7 @@ import { mapWhiteAsdfBlackQwerty, mapWhiteQweZxcBlack123Asd, } from "./keyboard-mapping"; -import { VirtualSynth } from "./virtual-synth"; -import { PingPongDelay, Synth } from "@/synth"; +import { useAudioStore } from "@/stores/audio"; import { Interval, parseLine, @@ -36,23 +35,8 @@ import { reverseParseScale, } from "scale-workshop-core"; -// === Root props and audio === -const rootProps = defineProps<{ - audioContext: AudioContext; -}>(); -// Protect the user's audio system by limiting -// the gain and frequency response. -const mainGain = ref(null); -const mainLowpass = ref(null); -const mainHighpass = ref(null); -// Keep a reference to the intended destination of the filter stack. -const audioDestination = ref(null); -// Chromium has some issues with audio nodes as props -// so we need this extra ref and the associated watcher. -const mainVolume = ref(0.175); -// Fix Firefox issues with audioContext.currentTime being in the past using a delay. -// This is a locally stored user preference, but shown on the Synth tab. -const audioDelay = ref(0.001); +// === Pinia-managed state === +const audio = useAudioStore(); // === Application state === const scaleName = ref(""); @@ -92,27 +76,12 @@ const midiInputChannels = reactive(new Set([1])); const midiOutputChannels = ref( new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16]) ); -const virtualSynth = reactive(new VirtualSynth(rootProps.audioContext)); const midiVelocityOn = ref(true); -// The synth needs to wait for a user gesture to initialize -const synth = ref(null); -// These are synth params that use watchers to relay their values to the synth -const waveform = ref("semisine"); -const attackTime = ref(0.01); -const decayTime = ref(0.3); -const sustainLevel = ref(0.8); -const releaseTime = ref(0.01); -const maxPolyphony = ref(6); + const midiWhiteMode = ref<"off" | "simple" | "blackAverage" | "keyColors">( "off" ); -// Stereo ping pong delay and associated params -const pingPongDelay = new PingPongDelay(rootProps.audioContext); -const pingPongGainNode = rootProps.audioContext.createGain(); -const pingPongDelayTime = ref(0.3); -const pingPongFeedback = ref(0.8); -const pingPongSeparation = ref(1); -const pingPongGain = ref(0); + // These are user preferences and are fetched from local storage. const newline = ref(UNIX_NEWLINE); const colorScheme = ref<"light" | "dark">("light"); @@ -238,15 +207,15 @@ const encodeState = debounce(() => { pianoMode: pianoMode.value, equaveShift: equaveShift.value, degreeShift: degreeShift.value, - waveform: waveform.value, - attackTime: attackTime.value, - decayTime: decayTime.value, - sustainLevel: sustainLevel.value, - releaseTime: releaseTime.value, - pingPongDelayTime: pingPongDelayTime.value, - pingPongFeedback: pingPongFeedback.value, - pingPongSeparation: pingPongSeparation.value, - pingPongGain: pingPongGain.value, + waveform: audio.waveform, + attackTime: audio.attackTime, + decayTime: audio.decayTime, + sustainLevel: audio.sustainLevel, + releaseTime: audio.releaseTime, + pingPongDelayTime: audio.pingPongDelayTime, + pingPongFeedback: audio.pingPongFeedback, + pingPongSeparation: audio.pingPongSeparation, + pingPongGain: audio.pingPongGain, }; const query = encodeQuery(state) as LocationQuery; @@ -268,27 +237,27 @@ watch(baseMidiNote, (newValue) => { }); watch( - [ - scaleName, - scaleLines, + () => [ + scaleName.value, + scaleLines.value, scale, - baseMidiNote, - keyColors, - isomorphicHorizontal, - isomorphicVertical, - keyboardMode, - pianoMode, - equaveShift, - degreeShift, - waveform, - attackTime, - decayTime, - sustainLevel, - releaseTime, - pingPongDelayTime, - pingPongFeedback, - pingPongSeparation, - pingPongGain, + baseMidiNote.value, + keyColors.value, + isomorphicHorizontal.value, + isomorphicVertical.value, + keyboardMode.value, + pianoMode.value, + equaveShift.value, + degreeShift.value, + audio.waveform, + audio.attackTime, + audio.decayTime, + audio.sustainLevel, + audio.releaseTime, + audio.pingPongDelayTime, + audio.pingPongFeedback, + audio.pingPongSeparation, + audio.pingPongGain, ], encodeState ); @@ -324,15 +293,15 @@ router.afterEach((to, from) => { updateFromScaleLines(state.scaleLines); equaveShift.value = state.equaveShift; degreeShift.value = state.degreeShift; - waveform.value = state.waveform; - attackTime.value = state.attackTime; - decayTime.value = state.decayTime; - sustainLevel.value = state.sustainLevel; - releaseTime.value = state.releaseTime; - pingPongDelayTime.value = state.pingPongDelayTime; - pingPongFeedback.value = state.pingPongFeedback; - pingPongSeparation.value = state.pingPongSeparation; - pingPongGain.value = state.pingPongGain; + audio.waveform = state.waveform; + audio.attackTime = state.attackTime; + audio.decayTime = state.decayTime; + audio.sustainLevel = state.sustainLevel; + audio.releaseTime = state.releaseTime; + audio.pingPongDelayTime = state.pingPongDelayTime; + audio.pingPongFeedback = state.pingPongFeedback; + audio.pingPongSeparation = state.pingPongSeparation; + audio.pingPongGain = state.pingPongGain; } catch (error) { console.error(`Error parsing version ${query.get("version")} URL`, error); } @@ -391,31 +360,15 @@ const midiOut = computed( function sendNoteOn(frequency: number, rawAttack: number) { const midiOff = midiOut.value.sendNoteOn(frequency, rawAttack); - if (audioDestination.value === null) { + if (audio.synth === null || audio.virtualSynth === null) { return midiOff; } - // Initialize synth on first user gesture. - if (synth.value === null) { - rootProps.audioContext.resume(); - synth.value = new Synth( - rootProps.audioContext, - audioDestination.value, - audioDelay.value, - waveform.value, - attackTime.value, - decayTime.value, - sustainLevel.value, - releaseTime.value, - maxPolyphony.value - ); - } - // Trigger web audio API synth. - const synthOff = synth.value.noteOn(frequency, rawAttack / 127); + const synthOff = audio.synth.noteOn(frequency, rawAttack / 127); // Trigger virtual synth for per-voice visualization. - const virtualOff = virtualSynth.voiceOn(frequency); + const virtualOff = audio.virtualSynth.voiceOn(frequency); const off = (rawRelease: number) => { midiOff(rawRelease); @@ -565,6 +518,9 @@ function keyboardNoteOn(index: number) { // === Typing keyboard state === function windowKeydownOrUp(event: KeyboardEvent | MouseEvent) { + // Audio context must be initialized as a response to user gesture + audio.initialize(); + const target = event.target; // Keep typing activated while adjusting sliders if ( @@ -674,6 +630,7 @@ onMounted(() => { window.addEventListener("keyup", windowKeydownOrUp); window.addEventListener("mousedown", windowKeydownOrUp); window.addEventListener("keydown", windowKeydown); + window.addEventListener("touchstart", audio.initialize); typingKeyboard.addKeydownListener(typingKeydown); const url = new URL(window.location.href); @@ -705,53 +662,18 @@ onMounted(() => { updateFromScaleLines(scaleWorkshopOneData.data.split(NEWLINE_TEST)); } - waveform.value = scaleWorkshopOneData.waveform || "semisine"; - attackTime.value = scaleWorkshopOneData.attackTime; - decayTime.value = scaleWorkshopOneData.decayTime; - sustainLevel.value = scaleWorkshopOneData.sustainLevel; - releaseTime.value = scaleWorkshopOneData.releaseTime; + audio.waveform = scaleWorkshopOneData.waveform || "semisine"; + audio.attackTime = scaleWorkshopOneData.attackTime; + audio.decayTime = scaleWorkshopOneData.decayTime; + audio.sustainLevel = scaleWorkshopOneData.sustainLevel; + audio.releaseTime = scaleWorkshopOneData.releaseTime; } catch (error) { console.error("Error parsing version 1 URL", error); } } - // Audio - const ctx = rootProps.audioContext; - - const gain = ctx.createGain(); - gain.gain.setValueAtTime(mainVolume.value, ctx.currentTime); - gain.connect(ctx.destination); - gain.connect(pingPongDelay.destination); - pingPongDelay.connect(pingPongGainNode).connect(ctx.destination); - mainGain.value = gain; - - const lowpass = ctx.createBiquadFilter(); - lowpass.frequency.setValueAtTime(5000, ctx.currentTime); - lowpass.Q.setValueAtTime(Math.sqrt(0.5), ctx.currentTime); - lowpass.type = "lowpass"; - lowpass.connect(gain); - mainLowpass.value = lowpass; - - const highpass = ctx.createBiquadFilter(); - highpass.frequency.setValueAtTime(30, ctx.currentTime); - highpass.Q.setValueAtTime(Math.sqrt(0.5), ctx.currentTime); - highpass.type = "highpass"; - highpass.connect(lowpass); - mainHighpass.value = highpass; - - // Intended point of audio connection - audioDestination.value = highpass; // Fetch user preferences const storage = window.localStorage; - if ("audioDelay" in storage) { - const value = storage.getItem("audioDelay"); - if (value !== null) { - audioDelay.value = parseFloat(value); - if (isNaN(audioDelay.value)) { - audioDelay.value = 0.0; - } - } - } if ("newline" in storage) { newline.value = storage.getItem("newline")!; } @@ -806,11 +728,6 @@ onMounted(() => { if ("degreeDownCode" in storage) { degreeDownCode.value = storage.getItem("degreeDownCode")!; } - - // Fetch synth max polyphony - if ("maxPolyphony" in storage) { - maxPolyphony.value = parseInt(storage.getItem("maxPolyphony")!); - } }); onUnmounted(() => { @@ -819,35 +736,12 @@ onUnmounted(() => { window.removeEventListener("keydown", windowKeydownOrUp); window.removeEventListener("keyup", windowKeydownOrUp); window.removeEventListener("mousedown", windowKeydownOrUp); + window.removeEventListener("touchstart", audio.initialize); typingKeyboard.removeEventListener(typingKeydown); if (midiInput.value !== null) { midiInput.value.removeListener(); } - // Audio - if (mainGain.value !== null) { - mainGain.value.disconnect(); - } - if (mainLowpass.value !== null) { - mainLowpass.value.disconnect(); - } - if (mainHighpass.value !== null) { - mainHighpass.value.disconnect(); - } - mainGain.value = null; - mainLowpass.value = null; - mainHighpass.value = null; - audioDestination.value = null; -}); - -watch(mainVolume, (newValue) => { - if (mainGain.value === null) { - return; - } - mainGain.value.gain.setTargetAtTime( - newValue, - rootProps.audioContext.currentTime, - 0.01 - ); + audio.unintialize(); }); function updateMidiInputChannels(newValue: Set) { @@ -864,99 +758,11 @@ function panic() { channels: [...midiOutputChannels.value], }); } - if (synth.value !== null) { - synth.value.allNotesOff(); + if (audio.synth !== null) { + audio.synth.allNotesOff(); } } -// Synth parameter watchers -watch(audioDelay, (newValue) => { - if (synth.value !== null) { - synth.value.audioDelay = newValue; - } -}); -watch(waveform, (newValue) => { - if (synth.value !== null) { - synth.value.waveform = newValue; - } -}); -watch(attackTime, (newValue) => { - if (synth.value !== null) { - synth.value.attackTime = newValue; - } -}); -watch(decayTime, (newValue) => { - if (synth.value !== null) { - synth.value.decayTime = newValue; - } -}); -watch(sustainLevel, (newValue) => { - if (synth.value !== null) { - synth.value.sustainLevel = newValue; - } -}); -watch(releaseTime, (newValue) => { - if (synth.value !== null) { - synth.value.releaseTime = newValue; - } -}); -watch(maxPolyphony, (newValue) => { - if (synth.value !== null) { - synth.value.maxPolyphony = newValue; - } -}); - -// Ping pong delay parameter watchers -watch( - pingPongDelayTime, - (newValue) => { - pingPongDelay.delayTime = newValue; - }, - { immediate: true } -); -watch( - pingPongFeedback, - (newValue) => { - pingPongDelay.feedback = newValue; - }, - { immediate: true } -); -watch( - pingPongSeparation, - (newValue) => { - pingPongDelay.separation = newValue; - }, - { immediate: true } -); -watch( - pingPongGain, - (newValue) => { - pingPongGainNode.gain.setValueAtTime( - newValue, - rootProps.audioContext.currentTime - ); - }, - { immediate: true } -); - -function setMaxPolyphony(newValue: number) { - if (newValue < 1) { - newValue = 1; - } - if (newValue > 128) { - newValue = 128; - } - if (!isNaN(newValue)) { - newValue = Math.round(newValue); - maxPolyphony.value = newValue; - window.localStorage.setItem("maxPolyphony", newValue.toString()); - } -} - -// Store user preferences -watch(audioDelay, (newValue) => - window.localStorage.setItem("audioDelay", newValue.toString()) -); watch(newline, (newValue) => window.localStorage.setItem("newline", newValue)); watch(colorScheme, (newValue) => { window.localStorage.setItem("colorScheme", newValue); @@ -1030,10 +836,6 @@ watch(degreeDownCode, (newValue) => :heldNotes="heldNotes" :midiInputChannels="midiInputChannels" :midiOutputChannels="midiOutputChannels" - :virtualSynth="virtualSynth" :newline="newline" :colorScheme="colorScheme" :centsFractionDigits="centsFractionDigits" @@ -1064,23 +865,11 @@ watch(degreeDownCode, (newValue) => :equaveDownCode="equaveDownCode" :degreeUpCode="degreeUpCode" :degreeDownCode="degreeDownCode" - :waveform="waveform" - :attackTime="attackTime" - :decayTime="decayTime" - :sustainLevel="sustainLevel" - :releaseTime="releaseTime" - :maxPolyphony="maxPolyphony" - :pingPongDelayTime="pingPongDelayTime" - :pingPongFeedback="pingPongFeedback" - :pingPongSeparation="pingPongSeparation" - :pingPongGain="pingPongGain" :typingKeyboard="typingKeyboard" :keyboardMapping="keyboardMapping" :showVirtualQwerty="showVirtualQwerty" :midiOctaveOffset="midiOctaveOffset" :intervalMatrixIndexing="intervalMatrixIndexing" - @update:audioDelay="audioDelay = $event" - @update:mainVolume="mainVolume = $event" @update:scaleName="scaleName = $event" @update:scaleLines="updateFromScaleLines" @update:scale="updateFromScale" @@ -1111,16 +900,6 @@ watch(degreeDownCode, (newValue) => @update:equaveDownCode="equaveDownCode = $event" @update:degreeUpCode="degreeUpCode = $event" @update:degreeDownCode="degreeDownCode = $event" - @update:waveform="waveform = $event" - @update:attackTime="attackTime = $event" - @update:decayTime="decayTime = $event" - @update:sustainLevel="sustainLevel = $event" - @update:releaseTime="releaseTime = $event" - @update:maxPolyphony="setMaxPolyphony" - @update:pingPongDelayTime="pingPongDelayTime = $event" - @update:pingPongFeedback="pingPongFeedback = $event" - @update:pingPongSeparation="pingPongSeparation = $event" - @update:pingPongGain="pingPongGain = $event" @panic="panic" /> diff --git a/src/components/ChordWheel.vue b/src/components/ChordWheel.vue index 470b62e6..685bcd26 100644 --- a/src/components/ChordWheel.vue +++ b/src/components/ChordWheel.vue @@ -9,7 +9,7 @@ const NUM_SAMPLES = 512; const props = defineProps<{ type: "otonal" | "utonal"; - virtualSynth: VirtualSynth; + virtualSynth: VirtualSynth | null; maxChordRoot: number; width: number; height: number; @@ -47,18 +47,18 @@ function draw(time: DOMHighResTimeStamp) { const synth = props.virtualSynth; + const ctx = canvas.value!.getContext("2d"); + if (ctx === null || synth === null) { + animationFrame = window.requestAnimationFrame(draw); + return; + } + if (isNaN(audioTimeOffset)) { audioTimeOffset = synth.audioContext.currentTime - end; } start += audioTimeOffset; end += audioTimeOffset; - const ctx = canvas.value!.getContext("2d"); - if (ctx === null) { - animationFrame = window.requestAnimationFrame(draw); - return; - } - const width = props.width; const height = props.height; const size = Math.min(width, height); diff --git a/src/main.ts b/src/main.ts index 3318e7d8..0b657019 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,25 +1,14 @@ import { createApp } from "vue"; +import { createPinia } from "pinia"; import App from "@/App.vue"; import router from "@/router"; -import { initializeCustomWaveforms } from "./synth"; - -// There should be only one audio context for the lifetime of the whole application -// so we define it here, outside of hot reloading. -const audioContext = new window.AudioContext({ latencyHint: "interactive" }); - -// Currently we get a warning: -// "An AudioContext was prevented from starting automatically. It must be created or resumed after a user gesture on the page." -// We could jump through a few app complicating hoops to prevent that, but we settle for suspension and resume after a user gesture. -audioContext.suspend(); - -// Custom waveforms need to be initialized only once. -initializeCustomWaveforms(audioContext); // Hack to bypass Vue state management for real-time gains in table row highlighting. (window as any).TUNING_TABLE_ROWS = Array(128); -const app = createApp(App, { audioContext }); +const app = createApp(App); +app.use(createPinia()); app.use(router); app.mount("#app"); diff --git a/src/stores/audio.ts b/src/stores/audio.ts new file mode 100644 index 00000000..1ee46185 --- /dev/null +++ b/src/stores/audio.ts @@ -0,0 +1,264 @@ +import { computed, ref, watch } from "vue"; +import { defineStore } from "pinia"; +import { Synth, initializeCustomWaveforms, PingPongDelay } from "../synth"; +import { VirtualSynth } from "../virtual-synth"; + +export const useAudioStore = defineStore("audio", () => { + const context = ref(null); + // Chromium has some issues with audio nodes as props + // so we need this extra ref and the associated watcher. + const mainVolume = ref(0.175); + // Protect the user's audio system by limiting + // the gain and frequency response. + const mainGain = ref(null); + const mainLowpass = ref(null); + const mainHighpass = ref(null); + const synth = ref(null); + + // Synth params + const waveform = ref("semisine"); + const attackTime = ref(0.01); + const decayTime = ref(0.3); + const sustainLevel = ref(0.8); + const releaseTime = ref(0.01); + // Fix Firefox issues with audioContext.currentTime being in the past using a delay. + // This is a locally stored user preference, but shown on the Synth tab. + const audioDelay = ref(0.001); + + // A virtual synth is used to "play" the chord wheels in the Analysis tab. + const virtualSynth = ref(null); + + // Stereo ping pong delay and associated params + const pingPongDelay = ref(null); + const pingPongGainNode = ref(null); + const pingPongDelayTime = ref(0.3); + const pingPongFeedback = ref(0.8); + const pingPongSeparation = ref(1); + const pingPongGain = ref(0); + + // Fetch user-specific state + if ("audioDelay" in window.localStorage) { + const value = window.localStorage.getItem("audioDelay"); + if (value !== null) { + audioDelay.value = parseFloat(value); + if (isNaN(audioDelay.value)) { + audioDelay.value = 0.001; + } + } + } + + const maxPolyphony = computed({ + get() { + if (synth.value === null) { + if ("maxPolyphony" in window.localStorage) { + return parseInt(window.localStorage.getItem("maxPolyphony")!); + } + return 6; + } + return synth.value.maxPolyphony; + }, + set(newValue) { + if (newValue < 1) { + newValue = 1; + } + if (newValue > 128) { + newValue = 128; + } + if (!isNaN(newValue)) { + newValue = Math.round(newValue); + window.localStorage.setItem("maxPolyphony", newValue.toString()); + if (synth.value !== null) { + synth.value.setPolyphony(newValue); + } + } + }, + }); + + function initialize() { + if (context.value) { + context.value.resume(); + return; + } + context.value = new AudioContext({ latencyHint: "interactive" }); + + pingPongDelay.value = new PingPongDelay(context.value); + pingPongGainNode.value = context.value.createGain(); + pingPongDelay.value.delayTime = pingPongDelayTime.value; + pingPongDelay.value.feedback = pingPongFeedback.value; + pingPongDelay.value.separation = pingPongFeedback.value; + pingPongGainNode.value.gain.setValueAtTime( + pingPongGain.value, + context.value.currentTime + ); + + const gain = context.value.createGain(); + gain.gain.setValueAtTime(mainVolume.value, context.value.currentTime); + gain.connect(context.value.destination); + gain.connect(pingPongDelay.value.destination); + pingPongDelay.value + .connect(pingPongGainNode.value) + .connect(context.value.destination); + mainGain.value = gain; + + const lowpass = context.value.createBiquadFilter(); + lowpass.frequency.setValueAtTime(5000, context.value.currentTime); + lowpass.Q.setValueAtTime(Math.sqrt(0.5), context.value.currentTime); + lowpass.type = "lowpass"; + lowpass.connect(gain); + mainLowpass.value = lowpass; + + const highpass = context.value.createBiquadFilter(); + highpass.frequency.setValueAtTime(30, context.value.currentTime); + highpass.Q.setValueAtTime(Math.sqrt(0.5), context.value.currentTime); + highpass.type = "highpass"; + highpass.connect(lowpass); + mainHighpass.value = highpass; + + // Intended point of audio connection + const audioDestination = highpass; + + initializeCustomWaveforms(context.value); + + synth.value = new Synth( + context.value, + audioDestination, + audioDelay.value, + waveform.value, + attackTime.value, + decayTime.value, + sustainLevel.value, + releaseTime.value, + maxPolyphony.value + ); + + virtualSynth.value = new VirtualSynth(context.value); + } + + async function unintialize() { + if (!context.value) { + return; + } + if (mainGain.value) { + mainGain.value.disconnect(); + } + if (mainLowpass.value) { + mainLowpass.value.disconnect(); + } + if (mainHighpass.value) { + mainHighpass.value.disconnect(); + } + if (synth.value) { + synth.value.setPolyphony(0); + } + await context.value.close(); + context.value = null; + } + + watch(mainVolume, (newValue) => { + if (!context.value || !mainGain.value) { + return; + } + mainGain.value.gain.setValueAtTime(newValue, context.value.currentTime); + }); + + watch(audioDelay, (newValue) => { + window.localStorage.setItem("audioDelay", newValue.toString()); + if (!synth.value) { + return; + } + synth.value.audioDelay = newValue; + }); + + watch(waveform, (newValue) => { + if (!synth.value) { + return; + } + synth.value.waveform = newValue; + }); + + watch(attackTime, (newValue) => { + if (!synth.value) { + return; + } + synth.value.attackTime = newValue; + }); + + watch(decayTime, (newValue) => { + if (!synth.value) { + return; + } + synth.value.decayTime = newValue; + }); + + watch(sustainLevel, (newValue) => { + if (!synth.value) { + return; + } + synth.value.sustainLevel = newValue; + }); + + watch(releaseTime, (newValue) => { + if (!synth.value) { + return; + } + synth.value.releaseTime = newValue; + }); + + // Ping pong delay parameter watchers + watch(pingPongDelayTime, (newValue) => { + if (!pingPongDelay.value) { + return; + } + pingPongDelay.value.delayTime = newValue; + }); + watch(pingPongFeedback, (newValue) => { + if (!pingPongDelay.value) { + return; + } + pingPongDelay.value.feedback = newValue; + }); + watch(pingPongSeparation, (newValue) => { + if (!pingPongDelay.value) { + return; + } + pingPongDelay.value.separation = newValue; + }); + watch(pingPongGain, (newValue) => { + if (!pingPongGainNode.value || !context.value) { + return; + } + pingPongGainNode.value.gain.setValueAtTime( + newValue, + context.value.currentTime + ); + }); + + return { + // Methods + initialize, + unintialize, + + // Public state + context, + mainVolume, + waveform, + attackTime, + decayTime, + sustainLevel, + releaseTime, + audioDelay, + maxPolyphony, + synth, + virtualSynth, + pingPongDelay, + pingPongDelayTime, + pingPongFeedback, + pingPongGain, + pingPongSeparation, + + // "Private" state must be exposed for Pinia + mainGain, + mainLowpass, + mainHighpass, + }; +}); diff --git a/src/synth.ts b/src/synth.ts index ca1faed9..aed7c486 100644 --- a/src/synth.ts +++ b/src/synth.ts @@ -5,7 +5,23 @@ const TIME_CONSTANT = 0.5; const EXPIRED = 10000; export const BASIC_WAVEFORMS = ["sine", "square", "sawtooth", "triangle"]; -export const CUSTOM_WAVEFORMS: { [key: string]: PeriodicWave } = {}; +export const WAVEFORMS = BASIC_WAVEFORMS.concat([ + "warm1", + "warm2", + "warm3", + "warm4", + "octaver", + "brightness", + "harmonicbell", + "semisine", + "rich", + "slender", + "didacus", + "bohlen", + "glass", + "boethius", +]); +const CUSTOM_WAVEFORMS: Record = {}; export function initializeCustomWaveforms(audioContext: AudioContext) { CUSTOM_WAVEFORMS.warm1 = audioContext.createPeriodicWave( diff --git a/src/views/AnalysisView.vue b/src/views/AnalysisView.vue index b8a204b9..944ba323 100644 --- a/src/views/AnalysisView.vue +++ b/src/views/AnalysisView.vue @@ -1,6 +1,5 @@