├── .gitignore ├── README.md ├── assets ├── tileable-metal-textures-5.jpg ├── waveform_sawtooth.svg ├── waveform_sine.svg ├── waveform_square.svg ├── waveform_triangle.svg └── wood-texture.png ├── index.html ├── package.json ├── src ├── constants.ts ├── dom.util.ts ├── index.ts ├── midi.util.ts ├── oscillator.factory.ts └── oscilloscope.ts ├── styles └── main.css ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | dist 4 | .idea 5 | .DS_Store 6 | .cache 7 | yarn-error.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Web Synthesizer From Space 👽 3 | 4 | DEMO here: http://synthfrom.space 5 | 6 | ## Install 7 | 8 | `npm i` to install, `npm start` to launch, `npm run build` to build 9 | 10 | ## Features 11 | 12 | - Monophonic 13 | - 2 oscillators 14 | - Low pass filter 15 | - Amplitude envelope 16 | - Virtual keyboard 17 | - Oscilloscope viz 18 | - Web MIDI support 19 | 20 | ![screenshot](https://user-images.githubusercontent.com/1481931/30545203-3de4a9a2-9c89-11e7-8660-5aa1a5084623.png) 21 | 22 | -------------------------------------------------------------------------------- /assets/tileable-metal-textures-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teebot/websynth/c1ceeed6d4f313651de5aa5795152c92f1f35b78/assets/tileable-metal-textures-5.jpg -------------------------------------------------------------------------------- /assets/waveform_sawtooth.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/waveform_sine.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/waveform_square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/waveform_triangle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/wood-texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teebot/websynth/c1ceeed6d4f313651de5aa5795152c92f1f35b78/assets/wood-texture.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Web Synthesizer From Space 👽 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

Permission

18 |

For the synthesizer to emit sounds we need your permission to play audio on this page

19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |

Web Synthesizer From Space 👽

29 | 30 |
31 |
32 |
33 | OSC1 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | 50 | 51 | 57 | 58 | 64 | 65 | 71 |
72 |
73 |
74 | 75 |
76 |
77 | OSC2 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |
89 | 95 | 96 | 102 | 103 | 109 | 110 | 116 |
117 |
118 |
119 | 120 |
121 |
122 | FILTER 123 | 124 | 125 | 126 | 127 | 128 |
129 | 130 |
131 | GLIDE 132 | 133 | 134 | 135 |
136 |
137 | 138 |
139 |
140 | ENVELOPE 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 |
153 |
154 |
155 | 156 |
157 |
    158 |
  • 159 |
  • 160 |
  • 161 |
  • 162 |
  • 163 |
  • 164 |
  • 165 |
  • 166 |
  • 167 |
  • 168 |
  • 169 |
  • 170 |
171 |
172 | MIDI or use computer keys a,s,d, ... 173 |
174 |
175 | 176 | 177 |
178 |
179 | 180 |
181 |
182 |
183 |
184 |
185 | 191 |
192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websynth", 3 | "version": "1.0.1", 4 | "scripts": { 5 | "start": "parcel index.html", 6 | "build": "parcel build index.html" 7 | }, 8 | "dependencies": { 9 | "rxjs": "6.5.2" 10 | }, 11 | "devDependencies": { 12 | "parcel": "^1.12.3", 13 | "@types/webmidi": "^2.0.3", 14 | "typescript": "3.5.3" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const KEYBOARD_MAPPING = { 2 | 'KeyA': 261.626, // C4 3 | 'KeyW': 277.183, // C# 4 4 | 'KeyS': 293.665, // D 4 5 | 'KeyE': 311.127, // ... 6 | 'KeyD': 329.628, 7 | 'KeyF': 349.228, 8 | 'KeyT': 369.994, 9 | 'KeyG': 391.995, 10 | 'KeyY': 415.305, 11 | 'KeyH': 440, 12 | 'KeyU': 466.164, 13 | 'KeyJ': 493.883, 14 | 'KeyK': 523.251, 15 | 'KeyO': 554.365, 16 | 'KeyL': 587.33, 17 | 'KeyP': 622.254, 18 | 'Semicolon': 659.255 19 | }; 20 | 21 | export const PIANO_MAPPING = { 22 | 'c': 261.626, // C4 23 | 'cs': 277.183, // C# 4 24 | 'd': 293.665, // D 4 25 | 'ds': 311.127, // ... 26 | 'e': 329.628, 27 | 'f': 349.228, 28 | 'fs': 369.994, 29 | 'g': 391.995, 30 | 'gs': 415.305, 31 | 'a': 440, 32 | 'as': 466.164, 33 | 'b': 493.883 34 | }; -------------------------------------------------------------------------------- /src/dom.util.ts: -------------------------------------------------------------------------------- 1 | // Create an observable of numbers emitted from a DOM range input 2 | import { fromEvent, Observable } from "rxjs"; 3 | import { map, startWith } from "rxjs/operators"; 4 | 5 | export const observeRange = ( 6 | selector: string, 7 | initialValue = 0 8 | ): Observable => 9 | fromEvent(document.querySelector(selector), "input").pipe( 10 | map((e: Event) => parseInt((e.target).value)), 11 | startWith(initialValue) 12 | ); 13 | 14 | // Create an observable of waveform from DOM radio buttons 15 | export const observeRadios = ( 16 | inputName: string, 17 | initialValue: any 18 | ): Observable => 19 | fromEvent( 20 | document.querySelectorAll(`input[name="${inputName}"]`), 21 | "change" 22 | ).pipe( 23 | map((radioEvent: Event) => (radioEvent.target).value), 24 | startWith(initialValue) 25 | ); 26 | 27 | export const observeCheckbox = ( 28 | selector: string, 29 | initialValue: boolean = false 30 | ) => 31 | fromEvent(document.querySelector(selector), "change").pipe( 32 | map((checkbox: Event) => (checkbox.target).checked), 33 | startWith(initialValue) 34 | ); 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { fromEvent, Observable, combineLatest, merge } from "rxjs"; 2 | import { 3 | mapTo, 4 | map, 5 | merge as mergeOp, 6 | scan, 7 | distinctUntilChanged, 8 | share, 9 | filter, 10 | combineLatest as combineLatestOp 11 | } from "rxjs/operators"; 12 | import { makeOscillator } from "./oscillator.factory"; 13 | import { KEYBOARD_MAPPING, PIANO_MAPPING } from "./constants"; 14 | import { observeRadios, observeRange, observeCheckbox } from "./dom.util"; 15 | import { drawOscilloscope } from "./oscilloscope"; 16 | import { midiInputTriggers$ } from "./midi.util"; 17 | const permissionDialog = document.querySelector(".permission") as HTMLElement; 18 | const allowAudioBtn = document.querySelector(".allow-audio-btn") as HTMLElement; 19 | 20 | const VALID_KEYS = Object.keys(KEYBOARD_MAPPING); 21 | const AudioContext = 22 | (window).AudioContext || (window).webkitAudioContext; 23 | 24 | const audioCtx = new AudioContext(); 25 | 26 | allowAudioBtn.addEventListener("click", function() { 27 | audioCtx.resume().then(() => { 28 | permissionDialog.style.display = "none"; 29 | console.log("Playback resumed successfully"); 30 | }); 31 | }); 32 | 33 | const lowpassFilter = audioCtx.createBiquadFilter(); 34 | const analyser = audioCtx.createAnalyser(); 35 | analyser.minDecibels = -90; 36 | analyser.maxDecibels = -10; 37 | analyser.smoothingTimeConstant = 0.85; 38 | 39 | lowpassFilter.type = "lowpass"; 40 | 41 | // create Oscillator/Gain nodes 42 | const oscillator1 = makeOscillator(audioCtx, 0, "sawtooth"); 43 | oscillator1.gainNode.connect(lowpassFilter); 44 | 45 | const oscillator2 = makeOscillator(audioCtx, 0, "square"); 46 | oscillator2.gainNode.connect(lowpassFilter); 47 | 48 | lowpassFilter.connect(analyser); 49 | analyser.connect(audioCtx.destination); 50 | 51 | // Create an observable from different source observables to emit a calculated frequency to play 52 | const observeFrequency = (initialFreq$, oct$, coarse$): Observable => 53 | initialFreq$.pipe( 54 | combineLatestOp(oct$, coarse$), 55 | map( 56 | ([initialFreq, octave, coarse]) => 57 | initialFreq * Math.pow(2, octave + coarse) 58 | ) 59 | ); 60 | 61 | // Observe notes played on the virtual piano and computer keyboard 62 | const pianoKeysReleased$ = fromEvent( 63 | document.querySelectorAll("ul.keys li"), 64 | "mouseup" 65 | ).pipe(mapTo(0)); 66 | const pianoKeysTouched$ = fromEvent( 67 | document.querySelectorAll("ul.keys li"), 68 | "mousedown" 69 | ).pipe(map((e: any) => PIANO_MAPPING[e.target.dataset.note])); 70 | const pianoKey$ = merge(pianoKeysTouched$, pianoKeysReleased$); 71 | 72 | const keyboardNotes$ = fromEvent(document, "keydown").pipe( 73 | mergeOp(fromEvent(document, "keyup")), 74 | scan((acc: Array, curr: KeyboardEvent) => { 75 | if (curr.type === "keyup") { 76 | return acc.filter(k => k !== curr.code); 77 | } 78 | if ( 79 | curr.type === "keydown" && 80 | acc.indexOf(curr.code) === -1 && 81 | VALID_KEYS.indexOf(curr.code) !== -1 82 | ) { 83 | return [...acc, curr.code]; 84 | } 85 | return acc; 86 | }, []), 87 | distinctUntilChanged(), 88 | map((keys: Array) => keys.map(k => KEYBOARD_MAPPING[k])), 89 | share() 90 | ); 91 | 92 | const monoNotePlayed$ = keyboardNotes$.pipe(map(notes => notes[0] || 0)); 93 | const monoMidiPlayed$ = midiInputTriggers$.pipe( 94 | map(midiTrig => (midiTrig[0] && midiTrig[0].pitch) || 0) 95 | ); 96 | const notePlayed$: Observable = merge( 97 | pianoKey$, 98 | monoNotePlayed$, 99 | monoMidiPlayed$ 100 | ); 101 | 102 | // Observe changes from DOM inputs 103 | const osc1oct$ = observeRange("#octave1", -1); 104 | const osc2oct$ = observeRange("#octave2", -1); 105 | 106 | const osc1coarse$ = observeRange("#coarse1").pipe(map(i => i / 100)); 107 | const osc2coarse$ = observeRange("#coarse2").pipe(map(i => i / 100)); 108 | 109 | const osc1Freq$ = observeFrequency(notePlayed$, osc1oct$, osc1coarse$); 110 | const osc2Freq$ = observeFrequency(notePlayed$, osc2oct$, osc2coarse$); 111 | 112 | const glideEnabled$ = observeCheckbox("#glideOn", false); 113 | const glide$: Observable = combineLatest( 114 | glideEnabled$, 115 | observeRange("#glide", 1).pipe(map(i => i / 10)) 116 | ).pipe(map(([glideEnabled, glide]) => (glideEnabled ? (glide as number) : 0))); 117 | 118 | // Main subscribes combining observables and setting the corresponding parameters 119 | 120 | const gain1$ = observeRange("#gain1", 10).pipe(map(i => i / 10)); 121 | const gain2$ = observeRange("#gain2", 10).pipe(map(i => i / 10)); 122 | 123 | const paramExp = divider => i => Math.exp(i / divider) - 1; 124 | const attack$ = observeRange("#attack", 0).pipe(map(paramExp(10))); 125 | const decay$ = observeRange("#decay", 0).pipe(map(i => i / 10)); 126 | const sustain$ = observeRange("#sustain", 10).pipe(map(i => i / 10)); 127 | const release$ = observeRange("#release", 0).pipe(map(paramExp(100))); 128 | 129 | // Apply envelope on oscillators gain 130 | combineLatest( 131 | notePlayed$, 132 | gain1$, 133 | gain2$, 134 | attack$, 135 | decay$, 136 | sustain$, 137 | release$ 138 | ).subscribe(([freq, gain1, gain2, attack, decay, sustain, release]: any) => { 139 | // NOTE OFF 140 | if (freq === 0) { 141 | envGenOn(oscillator1.gainNode.gain, release); 142 | envGenOn(oscillator2.gainNode.gain, release); 143 | // NOTE ON 144 | } else { 145 | envGenOff(oscillator1.gainNode.gain, gain1, attack, sustain, decay); 146 | envGenOff(oscillator2.gainNode.gain, gain2, attack, sustain, decay); 147 | } 148 | }); 149 | 150 | observeRadios("wave1", "sawtooth").subscribe( 151 | waveForm => (oscillator1.oscillatorNode.type = waveForm as OscillatorType) 152 | ); 153 | observeRadios("wave2", "sawtooth").subscribe( 154 | waveForm => (oscillator2.oscillatorNode.type = waveForm as OscillatorType) 155 | ); 156 | 157 | // TODO: Add optional enveloppe to these 2 combined 158 | observeRange("#cutoff", 20000).subscribe( 159 | freq => (lowpassFilter.frequency.value = freq) 160 | ); 161 | observeRange("#resonance", 0).subscribe(q => (lowpassFilter.Q.value = q)); 162 | 163 | // Apply note played 164 | combineLatest( 165 | osc1Freq$.pipe(filter(f => f !== 0)), 166 | osc2Freq$.pipe(filter(f => f !== 0)), 167 | glide$ 168 | ).subscribe(([osc1Freq, osc2Freq, glide]) => { 169 | setFreq(oscillator1.oscillatorNode, osc1Freq, glide); 170 | setFreq(oscillator2.oscillatorNode, osc2Freq, glide); 171 | }); 172 | 173 | drawOscilloscope("oscilloscope", analyser); 174 | 175 | // Set oscillator freq 176 | function setFreq(oscillatorNode: OscillatorNode, freq: number, glide: number) { 177 | oscillatorNode.frequency.cancelScheduledValues(audioCtx.currentTime); 178 | oscillatorNode.frequency.setValueAtTime( 179 | oscillatorNode.frequency.value, 180 | audioCtx.currentTime 181 | ); 182 | oscillatorNode.frequency.linearRampToValueAtTime( 183 | freq, 184 | audioCtx.currentTime + glide 185 | ); 186 | } 187 | 188 | // Envelope 189 | function envGenOn(param: AudioParam, release: number) { 190 | param.cancelScheduledValues(audioCtx.currentTime); 191 | param.setValueAtTime(param.value, audioCtx.currentTime); 192 | param.linearRampToValueAtTime(0, audioCtx.currentTime + release); // Release 193 | } 194 | 195 | function envGenOff( 196 | param: AudioParam, 197 | target: number, 198 | attackTime: number, 199 | sustainLevel: number, 200 | decayTime: number 201 | ) { 202 | param.cancelScheduledValues(audioCtx.currentTime); 203 | param.setValueAtTime(0, audioCtx.currentTime); 204 | param.linearRampToValueAtTime(target, audioCtx.currentTime + attackTime); // Attack 205 | param.linearRampToValueAtTime( 206 | Math.min(sustainLevel, target), 207 | audioCtx.currentTime + attackTime + decayTime 208 | ); // Decay 209 | } 210 | -------------------------------------------------------------------------------- /src/midi.util.ts: -------------------------------------------------------------------------------- 1 | import MIDIAccess = WebMidi.MIDIAccess; 2 | import MIDIInput = WebMidi.MIDIInput; 3 | import MIDIMessageEvent = WebMidi.MIDIMessageEvent; 4 | import { from, empty, Observable } from "rxjs"; 5 | import { 6 | map, 7 | mergeMap, 8 | scan, 9 | distinctUntilChanged, 10 | startWith, 11 | share 12 | } from "rxjs/operators"; 13 | 14 | const midiAccess$ = navigator.requestMIDIAccess 15 | ? from(navigator.requestMIDIAccess()) 16 | : empty(); 17 | enum keyPressed { 18 | On, 19 | Off 20 | } 21 | 22 | /** 23 | * Emits all midi inputs available 24 | * @type Observable> 25 | */ 26 | export const midiInputs$ = midiAccess$.pipe( 27 | map((midi: MIDIAccess) => { 28 | return Array.from(midi.inputs).map(([id, input]) => input); 29 | }) 30 | ); 31 | 32 | export const midiInputTriggers$: Observable = midiInputs$.pipe( 33 | mergeMap((inputs: any[]) => 34 | Observable.create(observer => 35 | inputs.forEach(i => (i.onmidimessage = event => observer.next(event))) 36 | ) 37 | ), 38 | map((m: MIDIMessageEvent) => { 39 | const [origin, key, velocity] = Array.from(m.data); 40 | const keyP = 41 | origin >= 144 && origin <= 159 && velocity > 0 42 | ? keyPressed.On 43 | : keyPressed.Off; 44 | return { pitch: mtof(key, 440), keyPressed: keyP }; 45 | }), 46 | scan((notes, note) => { 47 | if (note.keyPressed === keyPressed.On && notes.indexOf(note) === -1) { 48 | return [note, ...notes]; 49 | } 50 | return notes.filter(n => n.pitch !== n.pitch); 51 | }, []), 52 | distinctUntilChanged(), 53 | startWith([{ pitch: 0, keyPressed: keyPressed.Off }]), 54 | share() 55 | ); 56 | 57 | /** 58 | * midi note to frequency 59 | * https://github.com/kedromelon/mtof/blob/master/index.js 60 | * @param midiNote 61 | * @param concertPitch 62 | * @returns {number} 63 | */ 64 | function mtof(midiNote, concertPitch) { 65 | if (concertPitch === undefined) concertPitch = 440; 66 | 67 | if (typeof midiNote !== "number") { 68 | throw new TypeError("'mtof' expects its first argument to be a number."); 69 | } 70 | 71 | if (typeof concertPitch !== "number") { 72 | throw new TypeError("'mtof' expects its second argument to be a number."); 73 | } 74 | 75 | return Math.pow(2, (midiNote - 69) / 12) * concertPitch; 76 | } 77 | -------------------------------------------------------------------------------- /src/oscillator.factory.ts: -------------------------------------------------------------------------------- 1 | export function makeOscillator( 2 | audioCtx: AudioContext, 3 | initialValue = 440, 4 | initialWaveForm: OscillatorType = 'sawtooth' 5 | ): { oscillatorNode: OscillatorNode, gainNode: GainNode } { 6 | 7 | const gain = audioCtx.createGain(); 8 | 9 | const oscillator = audioCtx.createOscillator(); 10 | oscillator.type = initialWaveForm; 11 | oscillator.frequency.value = initialValue; // value in hertz 12 | oscillator.connect(gain); 13 | oscillator.start(); 14 | 15 | return { oscillatorNode: oscillator, gainNode: gain }; 16 | } 17 | -------------------------------------------------------------------------------- /src/oscilloscope.ts: -------------------------------------------------------------------------------- 1 | // Analyser 2 | import { interval } from "rxjs"; 3 | import { animationFrame } from "rxjs/internal/scheduler/animationFrame"; 4 | 5 | const BUFFER_SIZE = 2048; 6 | const DISPLAY_INTERVAL = 17; // = 60 fps 7 | 8 | export const drawOscilloscope = (elementId: string, analyser: AnalyserNode) => { 9 | const dataArray = new Uint8Array(BUFFER_SIZE); 10 | const oscilloscopeCanvas = document.getElementById( 11 | elementId 12 | ) as HTMLCanvasElement; 13 | const { width, height } = oscilloscopeCanvas.getBoundingClientRect(); 14 | oscilloscopeCanvas.width = width; 15 | oscilloscopeCanvas.height = height; 16 | const drawContext = oscilloscopeCanvas.getContext("2d"); 17 | 18 | interval(DISPLAY_INTERVAL, animationFrame).subscribe(i => { 19 | analyser.getByteTimeDomainData(dataArray); 20 | drawContext.fillStyle = "rgba(20,40,20,1.0)"; 21 | drawContext.fillRect(0, 0, width, height); 22 | drawContext.lineWidth = 1; 23 | drawContext.strokeStyle = "rgba(0,255,0,1.0)"; 24 | drawContext.beginPath(); 25 | const sliceWidth = (width * 1.0) / BUFFER_SIZE; 26 | let x = 0; 27 | for (let i = 0; i < BUFFER_SIZE; i++) { 28 | const v = dataArray[i] / 128.0; 29 | const y = (v * height) / 2; 30 | 31 | if (i === 0) { 32 | drawContext.moveTo(x, y); 33 | } else { 34 | drawContext.lineTo(x, y); 35 | } 36 | 37 | x += sliceWidth; 38 | } 39 | drawContext.lineTo(width, height / 2); 40 | drawContext.stroke(); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | color: #fff; 7 | background: linear-gradient( 8 | rgba(0, 0, 0, 0.71), 9 | rgba(0, 0, 0, 0.29), 10 | rgba(0, 0, 0, 0.71) 11 | ); 12 | } 13 | 14 | .permission { 15 | position: fixed; 16 | width: 100%; 17 | height: 100%; 18 | top: 0; 19 | left: 0; 20 | right: 0; 21 | bottom: 0; 22 | background-color: rgba(0, 0, 0, 0.5); 23 | z-index: 1; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | 29 | .permission-content { 30 | background: rgba(0, 0, 0, 0.8); 31 | padding: 30px; 32 | border-radius: 3px; 33 | margin: 0 auto; 34 | width: 500px; 35 | } 36 | 37 | .synth-container { 38 | margin-top: 2em; 39 | box-shadow: 0px 2px 12px 0 #000; 40 | } 41 | 42 | .synth-board { 43 | display: flex; 44 | justify-content: space-between; 45 | background: url(/assets/tileable-metal-textures-5.jpg); 46 | } 47 | 48 | .control-panel { 49 | flex: 1; 50 | padding: 2em; 51 | } 52 | 53 | .left-panel, 54 | .right-panel { 55 | width: 2em; 56 | background: url(/assets/wood-texture.png); 57 | } 58 | 59 | .top-panel, 60 | .bottom-panel { 61 | height: 2em; 62 | background: url(/assets/wood-texture.png); 63 | } 64 | 65 | form { 66 | display: flex; 67 | flex-wrap: wrap; 68 | align-content: space-around; 69 | margin: 0; 70 | } 71 | 72 | footer { 73 | margin: 12px; 74 | } 75 | 76 | .footer-links a { 77 | color: #fff; 78 | font-weight: bold; 79 | } 80 | 81 | .control-group fieldset { 82 | min-width: 220px; 83 | padding: 12px; 84 | border: 1px solid #fff; 85 | border-radius: 5px; 86 | background: linear-gradient(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.27)); 87 | box-shadow: 2px 4px 19px 0 #000; 88 | } 89 | 90 | label svg { 91 | width: 48px; 92 | height: 48px; 93 | fill: currentColor; 94 | } 95 | 96 | .waveforms { 97 | display: flex; 98 | justify-content: space-around; 99 | } 100 | 101 | .waveforms label { 102 | display: flex; 103 | flex-direction: column; 104 | align-items: center; 105 | } 106 | 107 | .synth-brand { 108 | font-size: 36px; 109 | font-family: cursive; 110 | background-color: rgba(0, 0, 0, 0.12); 111 | box-shadow: inset 0px 0px 19px 0 #000; 112 | display: inline-block; 113 | padding: 12px 24px; 114 | border-radius: 5px; 115 | color: #ffffff; 116 | } 117 | 118 | .control-group { 119 | padding: 4px; 120 | flex: 1; 121 | } 122 | 123 | /*RANGE CONTROLS*/ 124 | 125 | input[type="range"] { 126 | -webkit-appearance: none; 127 | width: 100%; 128 | } 129 | 130 | input[type="range"]:focus { 131 | outline: none; 132 | } 133 | 134 | input[type="range"]::-webkit-slider-runnable-track { 135 | width: 100%; 136 | height: 6px; 137 | cursor: pointer; 138 | animate: 0.2s; 139 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 140 | background: #3071a9; 141 | border-radius: 1.3px; 142 | border: 0.2px solid #010101; 143 | } 144 | 145 | input[type="range"]::-webkit-slider-thumb { 146 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 147 | border: 1px solid #000000; 148 | height: 24px; 149 | width: 12px; 150 | border-radius: 3px; 151 | background: #ffffff; 152 | cursor: pointer; 153 | -webkit-appearance: none; 154 | margin-top: -10px; 155 | } 156 | 157 | input[type="range"]:focus::-webkit-slider-runnable-track { 158 | background: #367ebd; 159 | } 160 | 161 | input[type="range"]::-moz-range-track { 162 | width: 100%; 163 | height: 8.4px; 164 | cursor: pointer; 165 | animate: 0.2s; 166 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 167 | background: #3071a9; 168 | border-radius: 1.3px; 169 | border: 0.2px solid #010101; 170 | } 171 | 172 | input[type="range"]::-moz-range-thumb { 173 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 174 | border: 1px solid #000000; 175 | height: 36px; 176 | width: 16px; 177 | border-radius: 3px; 178 | background: #ffffff; 179 | cursor: pointer; 180 | } 181 | 182 | input[type="range"]::-ms-track { 183 | width: 100%; 184 | height: 8.4px; 185 | cursor: pointer; 186 | animate: 0.2s; 187 | background: transparent; 188 | border-color: transparent; 189 | border-width: 16px 0; 190 | color: transparent; 191 | } 192 | 193 | input[type="range"]::-ms-fill-lower { 194 | background: #2a6495; 195 | border: 0.2px solid #010101; 196 | border-radius: 2.6px; 197 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 198 | } 199 | 200 | input[type="range"]::-ms-fill-upper { 201 | background: #3071a9; 202 | border: 0.2px solid #010101; 203 | border-radius: 2.6px; 204 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 205 | } 206 | 207 | input[type="range"]::-ms-thumb { 208 | box-shadow: 1px 1px 1px #000000, 0px 0px 1px #0d0d0d; 209 | border: 1px solid #000000; 210 | height: 36px; 211 | width: 16px; 212 | border-radius: 3px; 213 | background: #ffffff; 214 | cursor: pointer; 215 | } 216 | 217 | input[type="range"]:focus::-ms-fill-lower { 218 | background: #3071a9; 219 | } 220 | 221 | input[type="range"]:focus::-ms-fill-upper { 222 | background: #367ebd; 223 | } 224 | 225 | /*OSCILLOSCOPE*/ 226 | #oscilloscope { 227 | width: 440px; 228 | height: 200px; 229 | display: inline-block; 230 | border: 5px inset rgb(65, 23, 3); 231 | margin: 30px 0 0 20px; 232 | } 233 | 234 | /*KEYBOARD*/ 235 | 236 | .keyboard { 237 | box-shadow: 3px 3px 20px 4px #000; 238 | background: url(/assets/wood-texture.png); 239 | padding: 12px; 240 | display: inline-block; 241 | } 242 | 243 | .keyboard .notice { 244 | font-size: 10px; 245 | margin: 12px 0 0; 246 | } 247 | 248 | .keyboard svg { 249 | height: 40px; 250 | fill: currentColor; 251 | } 252 | 253 | ul.keys { 254 | display: flex; 255 | border-radius: 4px; 256 | margin: 0; 257 | } 258 | 259 | li { 260 | list-style: none; 261 | border: 1px solid #eee; 262 | border-top: 0; 263 | margin: 0; 264 | padding: 0; 265 | border-radius: 0 0 4px 4px; 266 | } 267 | 268 | li.white { 269 | height: 11em; 270 | width: 4em; 271 | background: linear-gradient(#f0f0f0, #fff); 272 | box-shadow: inset 2px -2px 0px 0 #fff; 273 | } 274 | 275 | li.white:active { 276 | border: 1px solid #999; 277 | box-shadow: inset 0px 1px 2px 0 #000; 278 | background: linear-gradient(#fff, #e9e9e9); 279 | } 280 | 281 | li.black { 282 | width: 2em; 283 | background: linear-gradient(#585858, #2b2b2b); 284 | box-shadow: inset 5px -10px 0px 0 #000; 285 | height: 6em; 286 | margin: 0 0 0 -1em; 287 | z-index: 2; 288 | } 289 | 290 | li.black:active { 291 | border: 1px solid #eee; 292 | box-shadow: inset 4px -4px 0px 0 #000; 293 | background: linear-gradient(#000, #4e4e4e); 294 | } 295 | 296 | .d, 297 | .e, 298 | .g, 299 | .a, 300 | .b { 301 | margin: 0 0 0 -1em; 302 | } 303 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "noImplicitAny": false, 5 | "sourceMap": true, 6 | "module": "commonjs", 7 | "typeRoots": ["node_modules/@types"], 8 | "lib": ["es6", "dom"] 9 | }, 10 | "include": [ 11 | "src/**/*" 12 | ], 13 | "exclude": [ 14 | "node_modules", 15 | "**/*.spec.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------