├── readme.md ├── app ├── meter.js ├── frequency-bars.js ├── index.html ├── app.js ├── notes.js ├── style.css └── tuner.js └── license /readme.md: -------------------------------------------------------------------------------- 1 | The online tuner based on web audio api: [https://qiuxiang.github.io/tuner/app](https://qiuxiang.github.io/tuner/app/). 2 | 3 | ![](https://user-images.githubusercontent.com/1709072/30374834-e23d0bc2-98b8-11e7-91ae-8ac37bfd24b2.png) 4 | -------------------------------------------------------------------------------- /app/meter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} selector 3 | * @constructor 4 | */ 5 | const Meter = function (selector) { 6 | this.$root = document.querySelector(selector); 7 | this.$pointer = this.$root.querySelector(".meter-pointer"); 8 | this.init(); 9 | }; 10 | 11 | Meter.prototype.init = function () { 12 | for (var i = 0; i <= 10; i += 1) { 13 | const $scale = document.createElement("div"); 14 | $scale.className = "meter-scale"; 15 | $scale.style.transform = "rotate(" + (i * 9 - 45) + "deg)"; 16 | if (i % 5 === 0) { 17 | $scale.classList.add("meter-scale-strong"); 18 | } 19 | this.$root.appendChild($scale); 20 | } 21 | }; 22 | 23 | /** 24 | * @param {number} deg 25 | */ 26 | Meter.prototype.update = function (deg) { 27 | this.$pointer.style.transform = "rotate(" + deg + "deg)"; 28 | }; 29 | -------------------------------------------------------------------------------- /app/frequency-bars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * the frequency histogram 3 | * 4 | * @param {string} selector 5 | * @constructor 6 | */ 7 | const FrequencyBars = function (selector) { 8 | this.$canvas = document.querySelector(selector); 9 | this.$canvas.width = document.body.clientWidth; 10 | this.$canvas.height = document.body.clientHeight / 2; 11 | this.canvasContext = this.$canvas.getContext("2d"); 12 | }; 13 | 14 | /** 15 | * @param {Uint8Array} data 16 | */ 17 | FrequencyBars.prototype.update = function (data) { 18 | const length = 64; // low frequency only 19 | const width = this.$canvas.width / length - 0.5; 20 | this.canvasContext.clearRect(0, 0, this.$canvas.width, this.$canvas.height); 21 | for (var i = 0; i < length; i += 1) { 22 | this.canvasContext.fillStyle = "#ecf0f1"; 23 | this.canvasContext.fillRect( 24 | i * (width + 0.5), 25 | this.$canvas.height - data[i], 26 | width, 27 | data[i] 28 | ); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Qiu Xiang 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Online Tuner 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | Hz 22 |
23 |
24 |
A4 = 440 Hz
25 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | const Application = function () { 2 | this.initA4(); 3 | this.tuner = new Tuner(this.a4); 4 | this.notes = new Notes(".notes", this.tuner); 5 | this.meter = new Meter(".meter"); 6 | this.frequencyBars = new FrequencyBars(".frequency-bars"); 7 | this.update({ 8 | name: "A", 9 | frequency: this.a4, 10 | octave: 4, 11 | value: 69, 12 | cents: 0, 13 | }); 14 | }; 15 | 16 | Application.prototype.initA4 = function () { 17 | this.$a4 = document.querySelector(".a4 span"); 18 | this.a4 = parseInt(localStorage.getItem("a4")) || 440; 19 | this.$a4.innerHTML = this.a4; 20 | }; 21 | 22 | Application.prototype.start = function () { 23 | const self = this; 24 | 25 | this.tuner.onNoteDetected = function (note) { 26 | if (self.notes.isAutoMode) { 27 | if (self.lastNote === note.name) { 28 | self.update(note); 29 | } else { 30 | self.lastNote = note.name; 31 | } 32 | } 33 | }; 34 | 35 | swal.fire("Welcome to online tuner!").then(function () { 36 | self.tuner.init(); 37 | self.frequencyData = new Uint8Array(self.tuner.analyser.frequencyBinCount); 38 | }); 39 | 40 | this.$a4.addEventListener("click", function () { 41 | swal 42 | .fire({ input: "number", inputValue: self.a4 }) 43 | .then(function ({ value: a4 }) { 44 | if (!parseInt(a4) || a4 === self.a4) { 45 | return; 46 | } 47 | self.a4 = a4; 48 | self.$a4.innerHTML = a4; 49 | self.tuner.middleA = a4; 50 | self.notes.createNotes(); 51 | self.update({ 52 | name: "A", 53 | frequency: self.a4, 54 | octave: 4, 55 | value: 69, 56 | cents: 0, 57 | }); 58 | localStorage.setItem("a4", a4); 59 | }); 60 | }); 61 | 62 | this.updateFrequencyBars(); 63 | 64 | document.querySelector(".auto input").addEventListener("change", () => { 65 | this.notes.toggleAutoMode(); 66 | }); 67 | }; 68 | 69 | Application.prototype.updateFrequencyBars = function () { 70 | if (this.tuner.analyser) { 71 | this.tuner.analyser.getByteFrequencyData(this.frequencyData); 72 | this.frequencyBars.update(this.frequencyData); 73 | } 74 | requestAnimationFrame(this.updateFrequencyBars.bind(this)); 75 | }; 76 | 77 | Application.prototype.update = function (note) { 78 | this.notes.update(note); 79 | this.meter.update((note.cents / 50) * 45); 80 | }; 81 | 82 | const app = new Application(); 83 | app.start(); 84 | -------------------------------------------------------------------------------- /app/notes.js: -------------------------------------------------------------------------------- 1 | const Notes = function (selector, tuner) { 2 | this.tuner = tuner; 3 | this.isAutoMode = true; 4 | this.$root = document.querySelector(selector); 5 | this.$notesList = this.$root.querySelector(".notes-list"); 6 | this.$frequency = this.$root.querySelector(".frequency"); 7 | this.$notes = []; 8 | this.$notesMap = {}; 9 | this.createNotes(); 10 | this.$notesList.addEventListener("touchstart", (event) => 11 | event.stopPropagation() 12 | ); 13 | }; 14 | 15 | Notes.prototype.createNotes = function () { 16 | this.$notesList.innerHTML = ""; 17 | const minOctave = 1; 18 | const maxOctave = 8; 19 | for (var octave = minOctave; octave <= maxOctave; octave += 1) { 20 | for (var n = 0; n < 12; n += 1) { 21 | const $note = document.createElement("div"); 22 | $note.className = "note"; 23 | $note.dataset.name = this.tuner.noteStrings[n]; 24 | $note.dataset.value = 12 * (octave + 1) + n; 25 | $note.dataset.octave = octave.toString(); 26 | $note.dataset.frequency = this.tuner.getStandardFrequency( 27 | $note.dataset.value 28 | ); 29 | $note.innerHTML = 30 | $note.dataset.name[0] + 31 | '' + 32 | ($note.dataset.name[1] || "") + 33 | "" + 34 | '' + 35 | $note.dataset.octave + 36 | ""; 37 | this.$notesList.appendChild($note); 38 | this.$notes.push($note); 39 | this.$notesMap[$note.dataset.value] = $note; 40 | } 41 | } 42 | 43 | const self = this; 44 | this.$notes.forEach(function ($note) { 45 | $note.addEventListener("click", function () { 46 | if (self.isAutoMode) { 47 | return; 48 | } 49 | 50 | const $active = self.$notesList.querySelector(".active"); 51 | if ($active === this) { 52 | self.tuner.stopOscillator(); 53 | $active.classList.remove("active"); 54 | } else { 55 | self.tuner.play(this.dataset.frequency); 56 | self.update($note.dataset); 57 | } 58 | }); 59 | }); 60 | }; 61 | 62 | Notes.prototype.active = function ($note) { 63 | this.clearActive(); 64 | $note.classList.add("active"); 65 | this.$notesList.scrollLeft = 66 | $note.offsetLeft - (this.$notesList.clientWidth - $note.clientWidth) / 2; 67 | }; 68 | 69 | Notes.prototype.clearActive = function () { 70 | const $active = this.$notesList.querySelector(".active"); 71 | if ($active) { 72 | $active.classList.remove("active"); 73 | } 74 | }; 75 | 76 | Notes.prototype.update = function (note) { 77 | if (note.value in this.$notesMap) { 78 | this.active(this.$notesMap[note.value]); 79 | this.$frequency.childNodes[0].textContent = parseFloat( 80 | note.frequency 81 | ).toFixed(1); 82 | } 83 | }; 84 | 85 | Notes.prototype.toggleAutoMode = function () { 86 | if (!this.isAutoMode) { 87 | this.tuner.stopOscillator(); 88 | } 89 | this.clearActive(); 90 | this.isAutoMode = !this.isAutoMode; 91 | }; 92 | -------------------------------------------------------------------------------- /app/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | position: fixed; 7 | font-family: sans-serif; 8 | color: #2c3e50; 9 | margin: 0; 10 | width: 100%; 11 | height: 100%; 12 | cursor: default; 13 | user-select: none; 14 | } 15 | 16 | .notes { 17 | margin: auto; 18 | width: 400px; 19 | position: fixed; 20 | top: 50%; 21 | left: 0; 22 | right: 0; 23 | text-align: center; 24 | } 25 | 26 | .note { 27 | font-size: 90px; 28 | font-weight: bold; 29 | position: relative; 30 | display: inline-block; 31 | padding-right: 30px; 32 | padding-left: 10px; 33 | } 34 | 35 | .note.active { 36 | color: #e74c3c; 37 | } 38 | 39 | .notes-list { 40 | overflow: auto; 41 | overflow: -moz-scrollbars-none; 42 | white-space: nowrap; 43 | -ms-overflow-style: none; 44 | -webkit-mask-image: -webkit-linear-gradient( 45 | left, 46 | rgba(255, 255, 255, 0), 47 | #fff, 48 | rgba(255, 255, 255, 0) 49 | ); 50 | } 51 | 52 | .notes-list::-webkit-scrollbar { 53 | display: none; 54 | } 55 | 56 | .note { 57 | -webkit-tap-highlight-color: transparent; 58 | } 59 | 60 | .note span { 61 | position: absolute; 62 | right: 0.25em; 63 | font-size: 40%; 64 | font-weight: normal; 65 | } 66 | 67 | .note-sharp { 68 | top: 0.3em; 69 | } 70 | 71 | .note-octave { 72 | bottom: 0.3em; 73 | } 74 | 75 | .frequency { 76 | font-size: 32px; 77 | } 78 | 79 | .frequency span { 80 | font-size: 50%; 81 | margin-left: 0.25em; 82 | } 83 | 84 | .meter { 85 | position: fixed; 86 | left: 0; 87 | right: 0; 88 | bottom: 50%; 89 | width: 400px; 90 | height: 33%; 91 | margin: 0 auto 5vh auto; 92 | } 93 | 94 | .meter-pointer { 95 | width: 2px; 96 | height: 100%; 97 | background: #2c3e50; 98 | transform: rotate(45deg); 99 | transform-origin: bottom; 100 | transition: transform 0.5s; 101 | position: absolute; 102 | right: 50%; 103 | } 104 | 105 | .meter-dot { 106 | width: 10px; 107 | height: 10px; 108 | background: #2c3e50; 109 | border-radius: 50%; 110 | position: absolute; 111 | bottom: -5px; 112 | right: 50%; 113 | margin-right: -4px; 114 | } 115 | 116 | .meter-scale { 117 | width: 1px; 118 | height: 100%; 119 | transform-origin: bottom; 120 | transition: transform 0.2s; 121 | box-sizing: border-box; 122 | border-top: 10px solid; 123 | position: absolute; 124 | right: 50%; 125 | } 126 | 127 | .meter-scale-strong { 128 | width: 2px; 129 | border-top-width: 20px; 130 | } 131 | 132 | .frequency-bars { 133 | position: fixed; 134 | bottom: 0; 135 | } 136 | 137 | @media (max-width: 768px) { 138 | .meter { 139 | width: 100%; 140 | } 141 | 142 | .notes { 143 | width: 100%; 144 | } 145 | } 146 | 147 | .swal-button { 148 | background: #2c3e50; 149 | } 150 | 151 | .a4 { 152 | position: absolute; 153 | top: 16px; 154 | left: 16px; 155 | } 156 | 157 | .a4 span { 158 | color: #e74c3c; 159 | } 160 | 161 | .auto { 162 | position: absolute; 163 | top: 16px; 164 | right: 16px; 165 | } 166 | -------------------------------------------------------------------------------- /app/tuner.js: -------------------------------------------------------------------------------- 1 | const Tuner = function (a4) { 2 | this.middleA = a4 || 440; 3 | this.semitone = 69; 4 | this.bufferSize = 4096; 5 | this.noteStrings = [ 6 | "C", 7 | "C♯", 8 | "D", 9 | "D♯", 10 | "E", 11 | "F", 12 | "F♯", 13 | "G", 14 | "G♯", 15 | "A", 16 | "A♯", 17 | "B", 18 | ]; 19 | 20 | this.initGetUserMedia(); 21 | }; 22 | 23 | Tuner.prototype.initGetUserMedia = function () { 24 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 25 | if (!window.AudioContext) { 26 | return alert("AudioContext not supported"); 27 | } 28 | 29 | // Older browsers might not implement mediaDevices at all, so we set an empty object first 30 | if (navigator.mediaDevices === undefined) { 31 | navigator.mediaDevices = {}; 32 | } 33 | 34 | // Some browsers partially implement mediaDevices. We can't just assign an object 35 | // with getUserMedia as it would overwrite existing properties. 36 | // Here, we will just add the getUserMedia property if it's missing. 37 | if (navigator.mediaDevices.getUserMedia === undefined) { 38 | navigator.mediaDevices.getUserMedia = function (constraints) { 39 | // First get ahold of the legacy getUserMedia, if present 40 | const getUserMedia = 41 | navigator.webkitGetUserMedia || navigator.mozGetUserMedia; 42 | 43 | // Some browsers just don't implement it - return a rejected promise with an error 44 | // to keep a consistent interface 45 | if (!getUserMedia) { 46 | alert("getUserMedia is not implemented in this browser"); 47 | } 48 | 49 | // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise 50 | return new Promise(function (resolve, reject) { 51 | getUserMedia.call(navigator, constraints, resolve, reject); 52 | }); 53 | }; 54 | } 55 | }; 56 | 57 | Tuner.prototype.startRecord = function () { 58 | const self = this; 59 | navigator.mediaDevices 60 | .getUserMedia({ audio: true }) 61 | .then(function (stream) { 62 | self.audioContext.createMediaStreamSource(stream).connect(self.analyser); 63 | self.analyser.connect(self.scriptProcessor); 64 | self.scriptProcessor.connect(self.audioContext.destination); 65 | self.scriptProcessor.addEventListener("audioprocess", function (event) { 66 | const frequency = self.pitchDetector.do( 67 | event.inputBuffer.getChannelData(0) 68 | ); 69 | if (frequency && self.onNoteDetected) { 70 | const note = self.getNote(frequency); 71 | self.onNoteDetected({ 72 | name: self.noteStrings[note % 12], 73 | value: note, 74 | cents: self.getCents(frequency, note), 75 | octave: parseInt(note / 12) - 1, 76 | frequency: frequency, 77 | }); 78 | } 79 | }); 80 | }) 81 | .catch(function (error) { 82 | alert(error.name + ": " + error.message); 83 | }); 84 | }; 85 | 86 | Tuner.prototype.init = function () { 87 | this.audioContext = new window.AudioContext(); 88 | this.analyser = this.audioContext.createAnalyser(); 89 | this.scriptProcessor = this.audioContext.createScriptProcessor( 90 | this.bufferSize, 91 | 1, 92 | 1 93 | ); 94 | 95 | const self = this; 96 | 97 | aubio().then(function (aubio) { 98 | self.pitchDetector = new aubio.Pitch( 99 | "default", 100 | self.bufferSize, 101 | 1, 102 | self.audioContext.sampleRate 103 | ); 104 | self.startRecord(); 105 | }); 106 | }; 107 | 108 | /** 109 | * get musical note from frequency 110 | * 111 | * @param {number} frequency 112 | * @returns {number} 113 | */ 114 | Tuner.prototype.getNote = function (frequency) { 115 | const note = 12 * (Math.log(frequency / this.middleA) / Math.log(2)); 116 | return Math.round(note) + this.semitone; 117 | }; 118 | 119 | /** 120 | * get the musical note's standard frequency 121 | * 122 | * @param note 123 | * @returns {number} 124 | */ 125 | Tuner.prototype.getStandardFrequency = function (note) { 126 | return this.middleA * Math.pow(2, (note - this.semitone) / 12); 127 | }; 128 | 129 | /** 130 | * get cents difference between given frequency and musical note's standard frequency 131 | * 132 | * @param {number} frequency 133 | * @param {number} note 134 | * @returns {number} 135 | */ 136 | Tuner.prototype.getCents = function (frequency, note) { 137 | return Math.floor( 138 | (1200 * Math.log(frequency / this.getStandardFrequency(note))) / Math.log(2) 139 | ); 140 | }; 141 | 142 | /** 143 | * play the musical note 144 | * 145 | * @param {number} frequency 146 | */ 147 | Tuner.prototype.play = function (frequency) { 148 | if (!this.oscillator) { 149 | this.oscillator = this.audioContext.createOscillator(); 150 | this.oscillator.connect(this.audioContext.destination); 151 | this.oscillator.start(); 152 | } 153 | this.oscillator.frequency.value = frequency; 154 | }; 155 | 156 | Tuner.prototype.stopOscillator = function () { 157 | if (this.oscillator) { 158 | this.oscillator.stop(); 159 | this.oscillator = null; 160 | } 161 | }; 162 | --------------------------------------------------------------------------------