├── .eslintrc.js ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── basic.html ├── context.html ├── events.html └── tone.html ├── images ├── volume-off.svg └── volume-on.svg ├── package-lock.json ├── package.json ├── scripts └── increment_version.js ├── src ├── AudioContext.js ├── AudioElement.js ├── ScriptElement.js ├── Toggle.js ├── Unmute.js └── unmute.scss ├── test ├── autoadd.html ├── blank.html ├── build-test.js ├── events.html ├── indirect_click.html ├── oldtone.html ├── referenceImage.png ├── test.js ├── tone.html └── tone_latest.html └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "node" : true, 5 | "mocha" : true, 6 | "es6": true, 7 | "amd" : false, 8 | }, 9 | "globals": { 10 | "UnmuteButton": true, 11 | "Tone": true 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2017, 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "sourceType": "module" 19 | }, 20 | "extends": ["eslint:recommended"], 21 | "rules": { 22 | "dot-location" : [ "error", "property" ], 23 | "linebreak-style": [ "error", "unix" ], 24 | "eqeqeq" : [ "error" ], 25 | "curly" : [ "error", "all" ], 26 | "dot-notation" : [ "error" ], 27 | "no-throw-literal" : [ "error" ], 28 | "no-useless-call" : [ "error" ], 29 | "no-unmodified-loop-condition": [ "error" ], 30 | "quote-props" : [ "error", "as-needed" /*"as-needed"*/ ], 31 | "quotes": [ "error","single" ], 32 | "no-lonely-if" : [ "error" ], 33 | "semi": [ "error", "never" ], 34 | //STYLE 35 | "indent": [ "error", "tab", { "SwitchCase": 1 } ], 36 | "no-multi-spaces" : [ "error" ], 37 | "array-bracket-spacing" : [ "error" , "never" ], 38 | "block-spacing": [ "error", "always" ], 39 | "func-call-spacing" : [ "error", "never" ], 40 | "key-spacing" : [ "error", {"beforeColon" : true, "afterColon" : true} ], 41 | "brace-style": [ "error", "1tbs" ], 42 | "space-in-parens": [ "error", "never" ], 43 | "eol-last": [ "error", "always" ], 44 | "lines-between-class-members": [ "error", "always" ], 45 | "no-multiple-empty-lines": [ "error", { "max": 1, "maxEOF": 1, "maxBOF": 0} ], 46 | "no-unneeded-ternary": [ "error" ], 47 | "object-curly-spacing": [ "error" , "always" ], 48 | "space-unary-ops": [ "error" , { "words" : true, "nonwords" : false } ], 49 | "block-spacing" : ["error", "always"], 50 | "keyword-spacing" : ["error", { "before": true }], 51 | "space-before-function-paren": ["error", {"anonymous": "never", "named": "never", "asyncArrow": "always"}], 52 | "comma-spacing": ["error", { "before": false, "after": true }], 53 | "space-before-blocks": ["error", { "functions": "never", "keywords": "never", "classes": "always" }] 54 | } 55 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | *.sublime-project 4 | *sublime-workspace 5 | test/testCapture.png 6 | build -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: node_js 4 | node_js: 5 | - '9' 6 | before_deploy: npm run increment 7 | deploy: 8 | - provider: npm 9 | skip_cleanup: true 10 | email: yotammann@gmail.com 11 | api_key: $NPM_TOKEN 12 | on: 13 | # don't publish on cron or PRs 14 | condition: $TRAVIS_EVENT_TYPE != cron && $TRAVIS_EVENT_TYPE != pull_request 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Yotam Mann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Tonejs/unmute.svg?branch=master)](https://travis-ci.org/Tonejs/unmute) 2 | 3 | Unmute adds a mute/unmute button to the top right corner of your page. 4 | 5 | This button implements many browsers' requirements that the AudioContext is started by a user action before it can play any sound. If the AudioContext is not running when the page is loaded, the button will initially be muted until a user clicks to unmute the button. 6 | 7 | [example](https://tonejs.github.io/unmute/examples/basic.html) 8 | 9 | ## INSTALLATION 10 | 11 | `npm install unmute` 12 | 13 | ## USAGE 14 | 15 | The mute button can be added to the page like so: 16 | 17 | ```javascript 18 | UnmuteButton() 19 | ``` 20 | 21 | ### es6 22 | 23 | ```javascript 24 | import UnmuteButton from 'unmute' 25 | 26 | UnmuteButton() 27 | ``` 28 | 29 | ### HTML 30 | 31 | If your code uses Tone.js, you can simply add the following code to your `` and it'll add an UnmuteButton to the page and bind itself to Tone.js' AudioContext. Tone.js must be included on the page. 32 | 33 | ```html 34 | 35 | ``` 36 | 37 | ## API 38 | 39 | 40 | ### Parameter 41 | 42 | UnmuteButton takes an optional object as a parameter. 43 | 44 | ```javascript 45 | UnmuteButton({ 46 | //the parent element of the mute button 47 | //can pass in "none" to create the element, but not add it to the DOM 48 | container : document.querySelector('#container'), 49 | //the title which appears on the iOS lock screen 50 | title : 'Web Audio', 51 | //force it to start muted, even when the AudioContext is running 52 | mute : false 53 | //AudioContext 54 | context : new AudioContext(), 55 | }) 56 | ``` 57 | #### `container` 58 | 59 | The HTMLElement which the button will be added to 60 | 61 | #### `title` 62 | 63 | UnmuteButton also unmutes the browser tab on iOS even when the mute toggle rocker switch is toggled on. This causes a title to appear on the phone's lock screen. The default title says "Web Audio" 64 | 65 | #### `mute` 66 | 67 | This will force the initial state of the button to be muted. Though, you _cannot_ force it to be 'unmuted' by passing in `{'mute' : false}` because the default state of the button is also determined by the state of the AudioContext. 68 | 69 | #### `context` 70 | 71 | If a context is passed in, it will be wrapped and available as a property of the returned object. If no context was passed in, one will be created. You can access the created context as a property. 72 | 73 | ```javascript 74 | const { context } = UnmuteButton() 75 | ``` 76 | 77 | ### Events 78 | 79 | UnmuteButton returns an event emitting object. 80 | 81 | #### 'start' 82 | 83 | Emitted when the AudioContext is started for the first time. 84 | 85 | ```javascript 86 | UnmuteButton().on('start', () => { 87 | //AudioContext.state is 'running' 88 | }) 89 | ``` 90 | 91 | #### 'mute' 92 | 93 | Emitted when the AudioContext is muted. 94 | 95 | #### 'unmute' 96 | 97 | Emitted when the AudioContext is unmuted. 98 | 99 | ## Methods 100 | 101 | ### `remove()` 102 | 103 | Removes the button element from its container 104 | 105 | ```javascript 106 | const unmute = UnmuteButton() 107 | //remove the element 108 | unmute.remove() 109 | ``` 110 | 111 | ## Style 112 | 113 | The UnmuteButton's default styling can be overwritten with css. The UnmuteButton is a ` 9 | 16 | 17 | -------------------------------------------------------------------------------- /test/oldtone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TEST 5 | 6 | 7 | 8 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /test/referenceImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tonejs/unmute/cfa012b62af7e48cc906f44d51de687cb9cde19d/test/referenceImage.png -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer') 2 | const { resolve } = require('path') 3 | const looksSame = require('looks-same') 4 | const { expect } = require('chai') 5 | 6 | describe('Unmute', () => { 7 | 8 | async function loadPage(url){ 9 | /*const browser = await puppeteer.launch({ 10 | args : ['--disable-features=MediaEngagementBypassAutoplayPolicies', '--user-gesture-required'], 11 | })*/ 12 | const browser = await puppeteer.launch() 13 | const page = await browser.newPage() 14 | await page.goto(`file://${resolve(__dirname, url)}`) 15 | return { page, browser } 16 | } 17 | 18 | it('loads on a page', async () => { 19 | const { browser } = await loadPage('blank.html') 20 | await browser.close() 21 | }) 22 | 23 | it('adds a button to the page', async () => { 24 | const { browser, page } = await loadPage('blank.html') 25 | const hasButton = await page.evaluate(() => { 26 | const context = new AudioContext() 27 | UnmuteButton({ context }) 28 | return Boolean(document.querySelector('#unmute-button')) 29 | }) 30 | expect(hasButton).to.be.true 31 | await page.screenshot({ path : resolve(__dirname, './testCapture.png') }) 32 | const similar = await new Promise((done, error) => { 33 | looksSame(resolve(__dirname, './referenceImage.png'), resolve(__dirname, './testCapture.png'), (e, equal) => { 34 | if (e){ 35 | error(e) 36 | } else { 37 | done(equal) 38 | } 39 | }) 40 | }) 41 | expect(similar).to.be.true 42 | 43 | await browser.close() 44 | }) 45 | 46 | it('can specify the container', async () => { 47 | const { browser, page } = await loadPage('blank.html') 48 | const correctParent = await page.evaluate(() => { 49 | const context = new AudioContext() 50 | const container = document.createElement('div') 51 | document.body.appendChild(container) 52 | UnmuteButton({ context, container }) 53 | return Boolean(document.querySelector('#unmute-button').parentNode === container) 54 | }) 55 | expect(correctParent).to.be.true 56 | await browser.close() 57 | }) 58 | 59 | it('should initially be muted', async () => { 60 | 61 | const { browser, page } = await loadPage('blank.html') 62 | const context = await page.evaluate(async () => { 63 | const context = new AudioContext() 64 | await context.suspend() 65 | const unmute = UnmuteButton({ context }) 66 | const className = unmute.element.classList[0] 67 | return { state : context.state, mute : unmute.mute, className } 68 | }) 69 | expect(context.state).to.equal('suspended') 70 | expect(context.className).to.equal('muted') 71 | expect(context.mute).to.be.true 72 | await browser.close() 73 | }) 74 | 75 | it('unmutes when clicked on', async () => { 76 | const { browser, page } = await loadPage('blank.html') 77 | //before being clicked 78 | const context = await page.evaluate(async () => { 79 | const context = new AudioContext() 80 | await context.suspend() 81 | window.unmute = UnmuteButton({ context }) 82 | return { state : context.state, mute : window.unmute.mute } 83 | }) 84 | expect(context.state).to.equal('suspended') 85 | expect(context.mute).to.be.true 86 | 87 | //click and wait a split second 88 | await page.click('#unmute-button') 89 | await page.waitFor(100) 90 | 91 | //check context and state again 92 | const afterClick = await page.evaluate(() => { 93 | return { state : window.unmute.context.state, mute : window.unmute.mute } 94 | }) 95 | expect(afterClick.mute).to.be.false 96 | expect(afterClick.state).to.equal('running') 97 | await browser.close() 98 | }) 99 | 100 | it('mutes if clicked on again', async () => { 101 | const { browser, page } = await loadPage('blank.html') 102 | await page.evaluate(async () => { 103 | const context = new AudioContext() 104 | await context.suspend() 105 | window.unmute = UnmuteButton({ context }) 106 | }) 107 | 108 | //unmute 109 | await page.click('#unmute-button') 110 | await page.waitFor(100) 111 | //remute it 112 | await page.click('#unmute-button') 113 | await page.waitFor(100) 114 | 115 | //check context and state again 116 | const afterClick = await page.evaluate(() => { 117 | return { state : window.unmute.context.state, mute : window.unmute.mute } 118 | }) 119 | expect(afterClick.mute).to.be.true 120 | expect(afterClick.state).to.equal('running') 121 | await browser.close() 122 | }) 123 | 124 | it('can remove the element', async () => { 125 | const { browser, page } = await loadPage('blank.html') 126 | //before being clicked 127 | const button = await page.evaluate(async () => { 128 | const context = new AudioContext() 129 | await context.suspend() 130 | const unmute = UnmuteButton({ context }) 131 | unmute.remove() 132 | return Boolean(document.querySelector('#unmute-button')) 133 | }) 134 | expect(button).to.be.false 135 | await browser.close() 136 | }) 137 | 138 | it('creates an AudioContext if none was passed in', async () => { 139 | const { browser, page } = await loadPage('blank.html') 140 | //before being clicked 141 | const hasContext = await page.evaluate(async () => { 142 | const unmute = UnmuteButton() 143 | return Boolean(unmute.context) 144 | }) 145 | expect(hasContext).to.be.true 146 | await browser.close() 147 | }) 148 | 149 | it('can be created with no args if tone is on the page', async () => { 150 | const { browser, page } = await loadPage('tone.html') 151 | const sameContext = await page.evaluate(() => { 152 | const unmute = UnmuteButton() 153 | return unmute.context === Tone.context 154 | }) 155 | expect(sameContext).to.be.true 156 | await browser.close() 157 | }) 158 | 159 | it('works with the latest version of Tone.js with no arguments', async () => { 160 | const { browser, page } = await loadPage('tone_latest.html') 161 | const sameContext = await page.evaluate(() => { 162 | const unmute = UnmuteButton() 163 | return unmute.context === Tone.context 164 | }) 165 | expect(sameContext).to.be.true 166 | await browser.close() 167 | }) 168 | 169 | it('can be used with an earlier version of Tone.js', async () => { 170 | const { browser, page } = await loadPage('oldtone.html') 171 | //mute the context 172 | const isMuted = await page.evaluate(async () => { 173 | window.unmute.mute = true 174 | await wait(100) 175 | return Tone.Master.mute 176 | }) 177 | expect(isMuted).to.be.true 178 | 179 | await browser.close() 180 | }) 181 | 182 | it('can start out muted', async () => { 183 | const { browser, page } = await loadPage('tone.html') 184 | const muted = await page.evaluate(() => { 185 | UnmuteButton({ mute : true }) 186 | return Tone.Master.mute 187 | }) 188 | expect(muted).to.be.true 189 | await browser.close() 190 | }) 191 | 192 | it('can add itself to the page', async () => { 193 | const { browser, page } = await loadPage('autoadd.html') 194 | const exists = await page.evaluate(() => { 195 | return Boolean(document.querySelector('#unmute-button')) 196 | }) 197 | expect(exists).to.be.true 198 | await browser.close() 199 | }) 200 | 201 | it('can mute it from Tone.js or by clicking', async () => { 202 | const { browser, page } = await loadPage('tone.html') 203 | const beforeClicked = await page.evaluate(async () => { 204 | window.unmute = UnmuteButton() 205 | Tone.Master.mute = true 206 | await wait(100) 207 | return window.unmute.mute 208 | }) 209 | 210 | expect(beforeClicked).to.be.true 211 | 212 | //click it 213 | await page.click('#unmute-button') 214 | await page.waitFor(100) 215 | 216 | //should be in the opposite state now 217 | const afterClicked = await page.evaluate(() => { 218 | return window.unmute.mute 219 | }) 220 | expect(afterClicked).to.be.false 221 | 222 | await browser.close() 223 | }) 224 | 225 | it('invokes start, mute, unmute events', async () => { 226 | const { browser, page } = await loadPage('events.html') 227 | 228 | //click it 229 | await page.click('#unmute-button') 230 | await page.waitFor(100) 231 | 232 | //click it again 233 | await page.click('#unmute-button') 234 | await page.waitFor(100) 235 | 236 | const { started, muted, unmuted } = await page.evaluate(() => events) 237 | expect(started).to.be.true 238 | expect(muted).to.be.true 239 | expect(unmuted).to.be.true 240 | 241 | await browser.close() 242 | }) 243 | 244 | it('works with a build', async () => { 245 | const { browser, page } = await loadPage('../build/index.html') 246 | 247 | //click it 248 | await page.click('#unmute-button') 249 | await page.waitFor(100) 250 | 251 | const sameContext = await page.evaluate(() => window.SAME_CONTEXT) 252 | expect(sameContext).to.be.true 253 | await browser.close() 254 | }) 255 | 256 | it('can click indirectly', async () => { 257 | const { browser, page } = await loadPage('indirect_click.html') 258 | 259 | //click it 260 | await page.click('#indirection') 261 | await page.waitFor(100) 262 | 263 | const isMuted = await page.evaluate(() => window.unmute.mute) 264 | expect(isMuted).to.be.false 265 | await browser.close() 266 | }) 267 | 268 | /** 269 | * Helper function to load a test html file and report any errors 270 | */ 271 | async function testExample(url){ 272 | return await new Promise(async (done, error) => { 273 | const examplePrefix = `file://${resolve(__dirname, '../examples')}` 274 | const browser = await puppeteer.launch() 275 | const page = await browser.newPage() 276 | page.on('pageerror', e => error(e)) 277 | await page.goto(`${examplePrefix}/${url}`, { waitFor : 'networkidle0' }) 278 | await browser.close() 279 | done() 280 | }) 281 | } 282 | 283 | /*it('can run all the examples', async () => { 284 | await testExample('basic.html') 285 | await testExample('context.html') 286 | await testExample('events.html') 287 | await testExample('tone.html') 288 | })*/ 289 | }) 290 | -------------------------------------------------------------------------------- /test/tone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TEST 5 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/tone_latest.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TEST 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HtmlWebpackPlugin = require('html-webpack-plugin') 3 | 4 | const commonConfig = { 5 | mode : 'development', 6 | context : __dirname, 7 | resolve : { 8 | modules : [ 9 | 'node_modules', 10 | path.resolve(__dirname, '.'), 11 | ], 12 | /*alias : { 13 | Tone : 'node_modules/tone/Tone' 14 | },*/ 15 | }, 16 | module : { 17 | rules : [ 18 | { 19 | test : /\.js$/, 20 | exclude : /node_modules/, 21 | loader : 'babel-loader' 22 | }, 23 | { 24 | test : /\.scss$/, 25 | loader : 'style-loader!css-loader!sass-loader' 26 | }, 27 | { 28 | test : /\.(svg)$/, 29 | loader : 'raw-loader' 30 | } 31 | ] 32 | }, 33 | devtool : 'source-map' 34 | } 35 | 36 | const libConfig = Object.assign({}, commonConfig, { 37 | entry : { 38 | unmute : './src/Unmute.js' 39 | }, 40 | output : { 41 | path : path.resolve(__dirname, 'build'), 42 | filename : '[name].js', 43 | library : 'UnmuteButton', 44 | libraryTarget : 'umd', 45 | libraryExport : 'UnmuteButton' 46 | }, 47 | }) 48 | 49 | const testConfig = Object.assign({}, commonConfig, { 50 | entry : { 51 | test : './test/build-test.js' 52 | }, 53 | output : { 54 | path : path.resolve(__dirname, 'build'), 55 | filename : '[name].js', 56 | }, 57 | plugins : [ 58 | new HtmlWebpackPlugin() 59 | ] 60 | }) 61 | 62 | module.exports = env => { 63 | if (env && env.test){ 64 | return testConfig 65 | } else { 66 | return libConfig 67 | } 68 | } 69 | --------------------------------------------------------------------------------