├── .watchmanconfig ├── public ├── robots.txt ├── icons │ ├── favicon.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-384x384.png │ └── icon-512x512.png ├── images │ └── github.png └── manifest.json ├── .travis └── deploy_key.enc ├── src ├── ui │ ├── styles │ │ ├── synth-keyboard.scss │ │ ├── synth-graphic.scss │ │ ├── synth-help.scss │ │ ├── base.scss │ │ ├── app.scss │ │ ├── synth-ctrl.scss │ │ ├── synth-volume.scss │ │ ├── synth-btn.scss │ │ └── synth-key.scss │ ├── components │ │ ├── concat │ │ │ ├── helper.ts │ │ │ └── helper-test.ts │ │ ├── if │ │ │ ├── helper.ts │ │ │ └── helper-test.ts │ │ ├── synth-graphic │ │ │ ├── template.hbs │ │ │ └── component.ts │ │ ├── synth-keyboard │ │ │ ├── template.hbs │ │ │ ├── component-test.ts │ │ │ └── component.ts │ │ ├── synth-btn │ │ │ └── template.hbs │ │ ├── synth-help │ │ │ ├── template.hbs │ │ │ └── component-test.ts │ │ ├── synth-volume │ │ │ ├── template.hbs │ │ │ ├── component.ts │ │ │ └── component-test.ts │ │ ├── synth-app │ │ │ ├── template.hbs │ │ │ ├── component-test.ts │ │ │ └── component.ts │ │ ├── synth-key │ │ │ ├── template.hbs │ │ │ └── component.ts │ │ └── synth-ctrl │ │ │ ├── template.hbs │ │ │ └── component.ts │ └── index.html ├── utils │ ├── test-helpers │ │ └── test-helper.ts │ ├── services │ │ ├── key.ts │ │ └── audio.ts │ ├── note.ts │ └── note-test.ts ├── main.ts └── index.ts ├── .gitignore ├── config ├── environment.js ├── module-map.d.ts ├── targets.js └── resolver-configuration.d.ts ├── testem.json ├── tslint.json ├── tsconfig.json ├── .editorconfig ├── .travis.yml ├── ember-cli-build.js ├── README.md ├── workers └── sw.js └── package.json /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.travis/deploy_key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/.travis/deploy_key.enc -------------------------------------------------------------------------------- /public/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/favicon.png -------------------------------------------------------------------------------- /public/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/images/github.png -------------------------------------------------------------------------------- /public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/ui/styles/synth-keyboard.scss: -------------------------------------------------------------------------------- 1 | .synth-keyboard { 2 | display: flex; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | -------------------------------------------------------------------------------- /public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theearthman81/glimmer-synth/HEAD/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/ui/components/concat/helper.ts: -------------------------------------------------------------------------------- 1 | export default function _concat(items: any[]): any { 2 | return items.join(''); 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/components/if/helper.ts: -------------------------------------------------------------------------------- 1 | export default function _if([test, truthy, falsy]: any[]): any { 2 | return test ? truthy : falsy; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tmp/* 3 | dist 4 | *.log 5 | .DS_Store 6 | .travis/deploy_key 7 | .travis/deploy_key.pem 8 | .travis/deploy_key.pub 9 | -------------------------------------------------------------------------------- /src/ui/components/synth-graphic/template.hbs: -------------------------------------------------------------------------------- 1 |
5 | 6 |
7 | -------------------------------------------------------------------------------- /src/ui/components/synth-keyboard/template.hbs: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/ui/components/synth-btn/template.hbs: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(environment) { 4 | let ENV = { 5 | modulePrefix: 'glimmer-synth', 6 | environment: environment 7 | }; 8 | 9 | return ENV; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/test-helpers/test-helper.ts: -------------------------------------------------------------------------------- 1 | import { setApp, start } from '@glimmer/test-helpers'; 2 | import App from '../../main'; 3 | 4 | QUnit.config.autostart = false; 5 | setApp(App); 6 | import '../../../tests'; 7 | start(); 8 | -------------------------------------------------------------------------------- /src/ui/styles/synth-graphic.scss: -------------------------------------------------------------------------------- 1 | $graphic-primary: #1A1A1A; 2 | 3 | .synth-graphic { 4 | background: $graphic-primary; 5 | box-shadow: inset 0 0 10px lighten($graphic-primary, 16%); 6 | filter: blur(1px); 7 | padding: 10px; 8 | } 9 | -------------------------------------------------------------------------------- /testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "qunit", 3 | "src_files": ["src/**/*"], 4 | "serve_files": ["index.js"], 5 | "disable_watching": true, 6 | "launch_in_ci": ["Chrome"], 7 | "browser_args": { 8 | "Chrome": ["--headless", "--disable-gpu", "--remote-debugging-port=9222"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/components/concat/helper-test.ts: -------------------------------------------------------------------------------- 1 | import _concat from './helper'; 2 | 3 | const { module, test } = QUnit; 4 | 5 | module('Helper: concat', function(hooks) { 6 | test('it computes', function(assert) { 7 | assert.equal(_concat([]), ''); 8 | 9 | assert.equal(_concat(['foo', 'bar']), 'foobar'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/ui/components/synth-help/template.hbs: -------------------------------------------------------------------------------- 1 |
2 | 3 | To toggle tips press "?" 4 | 5 | 6 | view it on github 7 | 8 |
9 | -------------------------------------------------------------------------------- /config/module-map.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is just a placeholder file to keep TypeScript aware editors happy. At build time, 3 | * it will be replaced with a complete map of resolvable module paths => rolled up contents. 4 | */ 5 | 6 | export interface Dict { 7 | [index: string]: T; 8 | } 9 | 10 | declare let map: Dict; 11 | export default map; 12 | -------------------------------------------------------------------------------- /src/ui/components/synth-volume/template.hbs: -------------------------------------------------------------------------------- 1 |
2 |
5 |
6 |
7 | {{#each ticks key="@index" as |tick|}} 8 |
9 | {{/each}} 10 |
11 |
12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-prettier" 5 | ], 6 | "rules": { 7 | "no-this-assignment": [true, { 8 | "allow-destructuring": true 9 | }], 10 | "only-arrow-functions": false, 11 | "variable-name": [true, 12 | "check-format", 13 | "allow-leading-underscore" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /config/targets.js: -------------------------------------------------------------------------------- 1 | let browsers = [ 2 | '> 5%', 3 | 'last 2 Edge versions', 4 | 'last 2 Chrome versions', 5 | 'last 2 Firefox versions', 6 | 'last 2 Safari versions', 7 | ]; 8 | 9 | if (process.env.EMBER_ENV === 'test') { 10 | browsers = [ 11 | 'last 1 Chrome versions', 12 | 'last 1 Firefox versions' 13 | ]; 14 | } 15 | 16 | module.exports = { browsers }; 17 | -------------------------------------------------------------------------------- /src/ui/components/if/helper-test.ts: -------------------------------------------------------------------------------- 1 | import _if from './helper'; 2 | 3 | const { module, test } = QUnit; 4 | 5 | module('Helper: if', function(hooks) { 6 | test('it computes', function(assert) { 7 | assert.equal(_if([]), undefined); 8 | 9 | assert.equal(_if([true, 'foo', 'bar']), 'foo'); 10 | 11 | assert.equal(_if([false, 'foo', 'bar']), 'bar'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es2015", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "moduleResolution": "node", 8 | "experimentalDecorators": true, 9 | "types": [ 10 | "qunit" 11 | ] 12 | }, 13 | "exclude": [ 14 | "node_modules", 15 | "tmp", 16 | "dist" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/components/synth-volume/component.ts: -------------------------------------------------------------------------------- 1 | import Component, { tracked } from '@glimmer/component'; 2 | 3 | export const ANGLE: number = 30; 4 | 5 | export default class SynthVolume extends Component { 6 | public ticks: number[] = [...Array(11).keys()]; 7 | 8 | @tracked('args') 9 | public get angle(): number { 10 | const max = ANGLE * (this.ticks.length - 1); 11 | return Math.min(this.args.volume * ANGLE, max); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /config/resolver-configuration.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is just a placeholder file to keep TypeScript aware editors happy. At build time, 3 | * it will be replaced with a resolver configuration composed from your application's 4 | * `config/environment.js` (and supplemented with default settings as possible). 5 | */ 6 | 7 | import { ResolverConfiguration } from '@glimmer/resolver'; 8 | declare var _default: ResolverConfiguration; 9 | export default _default; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.hbs] 17 | insert_final_newline = false 18 | 19 | [*.{diff,md}] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /src/ui/components/synth-app/template.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if supportsAudio}} 3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 | 12 | {{else}} 13 |

14 | Unfortunately your device does not support Web Audio. 15 |

16 | {{/if}} 17 |
18 | -------------------------------------------------------------------------------- /src/ui/components/synth-help/component-test.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import hbs from '@glimmer/inline-precompile'; 3 | import { setupRenderingTest } from '@glimmer/test-helpers'; 4 | 5 | const { module, test } = QUnit; 6 | 7 | module('Component: synth-help', function(hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | test('it renders correctly', async function(assert) { 11 | await this.render(hbs``); 12 | 13 | assert.ok(this.containerElement.querySelector('.synth-help')); 14 | 15 | assert.ok(this.containerElement.querySelector('.synth-help__content')); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/ui/components/synth-keyboard/component-test.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import hbs from '@glimmer/inline-precompile'; 3 | import { setupRenderingTest } from '@glimmer/test-helpers'; 4 | 5 | const { module, test } = QUnit; 6 | 7 | module('Component: synth-keyboard', function(hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | test('it renders correctly', async function(assert) { 11 | await this.render(hbs``); 12 | 13 | assert.ok(this.containerElement.querySelector('.synth-keyboard')); 14 | 15 | assert.equal( 16 | this.containerElement.querySelectorAll('.synth-key').length, 17 | 27 18 | ); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/ui/components/synth-key/template.hbs: -------------------------------------------------------------------------------- 1 |
  • 16 | 17 | {{#if @showTip}} 18 | {{@key.shortcut}} 19 | {{/if}} 20 | 21 |
  • 22 | -------------------------------------------------------------------------------- /src/ui/components/synth-app/component-test.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import hbs from '@glimmer/inline-precompile'; 3 | import { setupRenderingTest } from '@glimmer/test-helpers'; 4 | 5 | const { module, test } = QUnit; 6 | 7 | module('Component: synth-app', function(hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | test('it renders correctly', async function(assert) { 11 | await this.render(hbs``); 12 | 13 | assert.ok(this.containerElement.querySelector('.synth')); 14 | 15 | assert.ok(this.containerElement.querySelector('.synth__ctrl-wrapper')); 16 | 17 | assert.ok(this.containerElement.querySelector('.synth__keyboard-wrapper')); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/ui/styles/synth-help.scss: -------------------------------------------------------------------------------- 1 | .synth-help { 2 | align-items: center; 3 | background: #000; 4 | box-sizing: border-box; 5 | bottom: 0; 6 | color: #fff; 7 | display: flex; 8 | font-family: 'Roboto Mono', monospace; 9 | justify-content: space-between; 10 | left: 0; 11 | padding: 5px; 12 | position: fixed; 13 | width: 100%; 14 | z-index: 5; 15 | 16 | &__content { 17 | font-size: 0.7em; 18 | } 19 | 20 | &__link { 21 | color: #fff; 22 | font-size: 0.7em; 23 | text-decoration: none; 24 | } 25 | 26 | &__link:hover { 27 | text-decoration: underline; 28 | } 29 | 30 | &__link--icon { 31 | background: url(images/github.png) no-repeat left center; 32 | background-size: 1.2em 1.2em; 33 | padding-left: 1.6em; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '6' 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm run lint 11 | - npm run test 12 | - npm run build 13 | 14 | deploy: 15 | provider: surge 16 | project: ./dist/ 17 | domain: https://glmr-synth.surge.sh 18 | skip_cleanup: true 19 | 20 | after_deploy: 21 | - openssl aes-256-cbc -K $encrypted_6bdb3c89b407_key -iv $encrypted_6bdb3c89b407_iv 22 | -in ./.travis/deploy_key.enc -out ./.travis/deploy_key.pem -d 23 | - git checkout master 24 | - git pull 25 | - npm version --no-git-tag-version minor 26 | - git commit -am "Bump minor [ci skip]" 27 | - chmod 600 .travis/deploy_key.pem 28 | - eval "$(ssh-agent -s)" 29 | - ssh-add .travis/deploy_key.pem 30 | - git remote add deploy git@github.com:jimenglish81/glimmer-synth.git 31 | - git push deploy master 32 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-console */ 2 | import Application from '@glimmer/application'; 3 | import Resolver, { BasicModuleRegistry } from '@glimmer/resolver'; 4 | import moduleMap from '../config/module-map'; 5 | import resolverConfiguration from '../config/resolver-configuration'; 6 | 7 | export default class App extends Application { 8 | constructor() { 9 | const moduleRegistry = new BasicModuleRegistry(moduleMap); 10 | const resolver = new Resolver(resolverConfiguration, moduleRegistry); 11 | 12 | super({ 13 | resolver, 14 | rootName: resolverConfiguration.app.rootName, 15 | }); 16 | } 17 | } 18 | 19 | if ('serviceWorker' in navigator) { 20 | window.addEventListener('load', () => { 21 | navigator.serviceWorker 22 | .register('/sw.js') 23 | .then(({ scope }) => { 24 | console.log(`ServiceWorker success: ${scope}`); 25 | }) 26 | .catch(err => { 27 | console.log(`ServiceWorker failed: ${err}`); 28 | }); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/components/synth-app/component.ts: -------------------------------------------------------------------------------- 1 | import Component, { tracked } from '@glimmer/component'; 2 | import * as Rx from 'rxjs/Rx'; 3 | import { 4 | AudioService, 5 | default as audioService, 6 | } from '../../../utils/services/audio'; 7 | import { default as keyService, KeyService } from '../../../utils/services/key'; 8 | 9 | export default class SynthApp extends Component { 10 | @tracked public showTips: boolean = false; 11 | private keySub: Rx.Subscription; 12 | 13 | get audioService(): AudioService { 14 | return audioService; 15 | } 16 | 17 | get keyService(): KeyService { 18 | return keyService; 19 | } 20 | 21 | get supportsAudio(): boolean { 22 | return AudioService.supportsAudio; 23 | } 24 | 25 | public didInsertElement(): void { 26 | const { keyService: { keyup } } = this; 27 | this.keySub = keyup 28 | .filter(({ key }) => key === '?') 29 | .subscribe(() => (this.showTips = !this.showTips)); 30 | } 31 | 32 | public willDestroy(): void { 33 | this.keySub.unsubscribe(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/styles/base.scss: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Open+Sans:300|Gruppo:400|Roboto+Mono:400,800&subset=latin); 2 | 3 | @mixin help-tip() { 4 | color: #4cbb17; 5 | text-shadow: 0 0 3px #4cbb17; 6 | font-family: 'Roboto Mono', monospace; 7 | font-weight: 800; 8 | } 9 | 10 | ::selection { 11 | background: #fefec8; 12 | } 13 | 14 | body { 15 | -webkit-tap-highlight-color: transparent; 16 | background-color: #6c2505; 17 | background-image: 18 | linear-gradient(94deg, rgba(91,33,5,.1) 0%,rgba(83,29,4,0.32) 23%,rgba(74,24,3,.41) 47%,rgba(80,27,5,0.44) 70%,rgba(81,27,5,0.59) 74%,rgba(93,33,4,0.2) 83%,rgba(115,43,3,.5) 100%), 19 | linear-gradient(90deg, #541c09 50%, transparent 50%), 20 | linear-gradient(90deg, #7a2e00 50%, #632401 50%); 21 | background-size: 40px 160px, 60px 29px, 27px 27px; 22 | font-family: "Open Sans", sans-serif; 23 | margin: 0; 24 | padding: 0; 25 | -moz-user-select: none; 26 | -webkit-user-select: none; 27 | -ms-user-select: none; 28 | user-select: none; 29 | } 30 | 31 | .warning { 32 | color: #fff; 33 | } 34 | -------------------------------------------------------------------------------- /src/ui/components/synth-ctrl/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | {{#if supportsRecording}} 5 | 12 | 19 | {{/if}} 20 |
    21 | 25 |
    26 | 30 |
    31 | 32 | {{#if @showTips}} 33 | {{#if supportsRecording}} 34 |
    1
    35 |
    2
    36 | {{/if}} 37 |
    -/+
    38 | {{/if}} 39 |
    40 | -------------------------------------------------------------------------------- /src/utils/services/key.ts: -------------------------------------------------------------------------------- 1 | import * as Rx from 'rxjs/Rx'; 2 | 3 | interface IKeyEvent { 4 | type: string; 5 | key: string; 6 | keyCode: number; 7 | } 8 | 9 | export class KeyService { 10 | private _events: Rx.Observable; 11 | 12 | public get keydown(): Rx.Observable { 13 | return this.keypress.filter(({ type }) => type === 'keydown'); 14 | } 15 | 16 | public get keypress(): Rx.Observable { 17 | if (!this._events) { 18 | const keyDowns = Rx.Observable.fromEvent(document, 'keydown'); 19 | const keyUps = Rx.Observable.fromEvent(document, 'keyup'); 20 | 21 | this._events = Rx.Observable 22 | .merge(keyDowns, keyUps) 23 | .map(({ type, key, keyCode, which }) => ({ 24 | key: key || which, 25 | keyCode, 26 | type, 27 | })) 28 | .groupBy(e => e.keyCode) 29 | .map(group => group.distinctUntilChanged(null, e => e.type)) 30 | .mergeAll(); 31 | } 32 | return this._events; 33 | } 34 | 35 | public get keyup(): Rx.Observable { 36 | return this.keypress.filter(({ type }) => type === 'keyup'); 37 | } 38 | } 39 | 40 | export default new KeyService(); 41 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-console */ 2 | import { ComponentManager, setPropertyDidChange } from '@glimmer/component'; 3 | import App from './main'; 4 | 5 | const app = new App(); 6 | const containerElement = document.getElementById('app'); 7 | 8 | setPropertyDidChange(() => { 9 | app.scheduleRerender(); 10 | }); 11 | 12 | app.registerInitializer({ 13 | initialize(registry) { 14 | registry.register( 15 | `component-manager:/${app.rootName}/component-managers/main`, 16 | ComponentManager 17 | ); 18 | }, 19 | }); 20 | 21 | app.renderComponent('synth-app', containerElement, null); 22 | 23 | app.boot(); 24 | 25 | console.log( 26 | `%cWelcome To GLMR Synth! 27 | %c 28 | _______________________________________' 29 | | GLMR-SYNTH |... . | | 30 | | ::: |... .... . | | 31 | | ::: |.............| | 32 | |_______________________________________| 33 | | |█| |█| | |█| |█| |█| | |█| |█| | 34 | | |█| |█| | |█| |█| |█| | |█| |█| | 35 | | |█| |█| | |█| |█| |█| | |█| |█| | 36 | | | | | | | | | | | | 37 | |___|___|___|___|___|___|___|___|___|___| 38 | `, 39 | 'color: #222; text-shadow: 1px 1px 1px #bada55;', 40 | 'background: #222; color: #bada55' 41 | ); 42 | -------------------------------------------------------------------------------- /src/ui/styles/app.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | @import 'synth-ctrl'; 3 | @import 'synth-volume'; 4 | @import 'synth-graphic'; 5 | @import 'synth-btn'; 6 | @import 'synth-key'; 7 | @import 'synth-keyboard'; 8 | @import 'synth-help'; 9 | 10 | #app { 11 | display: block; 12 | } 13 | 14 | .synth-wrapper { 15 | align-items: center; 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | .synth { 21 | width: 1024px; 22 | 23 | &__keyboard-wrapper { 24 | padding: 0 0 0 1.6%; 25 | } 26 | } 27 | 28 | /*** media queries ***/ 29 | 30 | @media (max-width: 768px) { 31 | .synth { 32 | width: 568px; 33 | } 34 | 35 | .synth-btn { 36 | display: none; 37 | } 38 | 39 | .synth-ctrl__logo { 40 | font-size: 0.8em; 41 | } 42 | 43 | .synth-ctrl__volume { 44 | display: none; 45 | } 46 | 47 | .synth-help { 48 | display: none; 49 | } 50 | 51 | .synth-key--black { 52 | height: 112px; 53 | } 54 | 55 | .synth-key--white { 56 | height: 180px; 57 | } 58 | } 59 | 60 | @media (min-width: 580px) 61 | and (max-width: 768px) { 62 | .synth-btn { 63 | display: block; 64 | } 65 | 66 | .synth { 67 | width: 620px; 68 | } 69 | } 70 | 71 | @media (max-height: 280px) { 72 | .synth-ctrl { 73 | display: none; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { GlimmerApp } = require('@glimmer/application-pipeline'); 4 | const { log } = require('broccoli-stew'); 5 | const commonjs = require('rollup-plugin-commonjs'); 6 | const Funnel = require('broccoli-funnel'); 7 | const merge = require('broccoli-merge-trees'); 8 | const replace = require('broccoli-string-replace'); 9 | 10 | module.exports = function(defaults) { 11 | const app = new GlimmerApp(defaults, { 12 | 'ember-cli-uglify': { 13 | uglify: { 14 | mangle: { 15 | safari10: true, 16 | }, 17 | }, 18 | }, 19 | fingerprint: { 20 | assetMapPath: './asset-map.json', 21 | generateAssetMap: true, 22 | replaceExtensions: ['html', 'css', 'js', 'json'], 23 | }, 24 | rollup: { 25 | plugins: [ 26 | commonjs(), 27 | ], 28 | }, 29 | }); 30 | 31 | const workers = replace( 32 | new Funnel('workers', { 33 | srcDir: '/', 34 | destDir: '/', 35 | include: ['**/*.js'], 36 | }), 37 | { 38 | files: ['sw.js'], 39 | pattern: { 40 | match: /<@VERSION@>/g, 41 | replacement: require('./package.json').version, 42 | }, 43 | } 44 | ); 45 | 46 | return log(merge([app.toTree(), workers]), { 47 | output: 'tree', 48 | label: 'app-tree', 49 | }); 50 | }; 51 | -------------------------------------------------------------------------------- /src/ui/styles/synth-ctrl.scss: -------------------------------------------------------------------------------- 1 | .synth-ctrl { 2 | background-image: linear-gradient(0deg, #1a1a1a, #0d0d0d); 3 | box-shadow: inset 0 0 15px #444; 4 | display: flex; 5 | justify-content: space-between; 6 | padding: 10px; 7 | position: relative; 8 | 9 | &__btns { 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: space-between; 13 | } 14 | 15 | &__volume { 16 | align-items: center; 17 | display: flex; 18 | padding: 10px; 19 | } 20 | 21 | &__logo { 22 | color: transparent; 23 | font-family: 'Gruppo', fantasy; 24 | font-size: 1.375em; 25 | letter-spacing: 0.1em; 26 | margin: 0; 27 | text-align: left; 28 | text-shadow: 29 | 0 0 2px rgba(42, 40, 41, 0.9), 0 1px 2px rgba(255, 255, 255, 0.3), 30 | 0 -2px 8px rgba(255, 255, 255, 0.1), 0 -1px 3px rgba(0, 0, 0, 0.5), 31 | 0 1px 1px rgba(255, 255, 255, 0.3), 0 1px 1px rgba(0, 0, 0, 0.2), 32 | 0 0 4px rgba(0, 0, 0, 0.45); 33 | text-transform: uppercase; 34 | } 35 | 36 | &__help { 37 | @include help-tip() 38 | position: absolute; 39 | z-index: 5; 40 | 41 | &--play { 42 | top: 118px; 43 | left: 90px; 44 | } 45 | 46 | &--record { 47 | left: 90px; 48 | top: 60px; 49 | } 50 | 51 | &--volume { 52 | right: 120px; 53 | top: 67px; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Glimmer Synth", 3 | "short_name": "Glmr Synth", 4 | "theme_color": "#fff", 5 | "background_color": "#fff", 6 | "display": "standalone", 7 | "description": "Glimmerjs synthesizer using WebAudio API.", 8 | "orientation": "landscape", 9 | "Scope": "/", 10 | "start_url": "/", 11 | "icons": [ 12 | { 13 | "src": "icons/icon-72x72.png", 14 | "sizes": "72x72", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "icons/icon-96x96.png", 19 | "sizes": "96x96", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png" 26 | }, 27 | { 28 | "src": "icons/icon-144x144.png", 29 | "sizes": "144x144", 30 | "type": "image/png" 31 | }, 32 | { 33 | "src": "icons/icon-152x152.png", 34 | "sizes": "152x152", 35 | "type": "image/png" 36 | }, 37 | { 38 | "src": "icons/icon-192x192.png", 39 | "sizes": "192x192", 40 | "type": "image/png" 41 | }, 42 | { 43 | "src": "icons/icon-384x384.png", 44 | "sizes": "384x384", 45 | "type": "image/png" 46 | }, 47 | { 48 | "src": "icons/icon-512x512.png", 49 | "sizes": "512x512", 50 | "type": "image/png" 51 | } 52 | ], 53 | "splash_pages": null 54 | } 55 | -------------------------------------------------------------------------------- /src/ui/components/synth-key/component.ts: -------------------------------------------------------------------------------- 1 | import Component, { tracked } from '@glimmer/component'; 2 | import * as Rx from 'rxjs/Rx'; 3 | import { Note } from '../../../utils/note'; 4 | import { 5 | AudioService, 6 | default as audioService, 7 | } from '../../../utils/services/audio'; 8 | import { default as keyService, KeyService } from '../../../utils/services/key'; 9 | 10 | export default class SynthKey extends Component { 11 | private _note: Note; 12 | private keySub: Rx.Subscription; 13 | @tracked private isActive: boolean; 14 | 15 | public start(): void { 16 | this.note.start(); 17 | this.isActive = true; 18 | } 19 | 20 | public stop(): void { 21 | this.note.stop(); 22 | this.isActive = false; 23 | } 24 | 25 | public didInsertElement(): void { 26 | const { args: { key: { shortcut } }, keyService: { keypress } } = this; 27 | this.keySub = keypress 28 | .filter(({ key }) => key === shortcut) 29 | .subscribe(() => (this.isActive ? this.stop() : this.start())); 30 | } 31 | 32 | public willDestroy(): void { 33 | this.keySub.unsubscribe(); 34 | } 35 | 36 | get audioService(): AudioService { 37 | return audioService; 38 | } 39 | 40 | @tracked('args') 41 | get keyName(): string { 42 | return this.args.key.name.toUpperCase(); 43 | } 44 | 45 | get keyService(): KeyService { 46 | return keyService; 47 | } 48 | 49 | get note(): Note { 50 | if (!this._note) { 51 | const { key: { name, octave } } = this.args; 52 | this._note = this.audioService.createNote(name, octave); 53 | } 54 | return this._note; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GLMR-Synth 7 | 8 | 9 | 10 | 11 | 12 | {{content-for "head"}} 13 | 25 | 26 | 27 | 28 | 29 | 30 | {{content-for "head-footer"}} 31 | 32 | 33 | {{content-for "body"}} 34 | 35 |
    36 | 37 | 42 | 47 | 48 | {{content-for "body-footer"}} 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/ui/styles/synth-volume.scss: -------------------------------------------------------------------------------- 1 | $volume-primary: #181818; 2 | 3 | .synth-volume { 4 | font-family: 'Roboto Mono', monospace; 5 | position: relative; 6 | width: 60px; 7 | height: 60px; 8 | border-radius: 50%; 9 | border: solid 14px darken($volume-primary, 4%); 10 | background: 11 | -webkit-gradient( 12 | linear, left bottom, left top, 13 | color-stop(0, lighten($volume-primary, 2%)), 14 | color-stop(1, darken($volume-primary, 2%)) 15 | ); 16 | box-shadow: 17 | 0 0.2em 0.1em 0.05em transparentize(darken($volume-primary, 10%), 0.1) inset, 18 | 0 -0.2em 0.1em 0.05em transparentize(darken($volume-primary, 10%), 0.5) inset, 19 | 0 0.5em 0.65em 0 transparentize(darken($volume-primary, 10%), 0.3); 20 | 21 | &__tick { 22 | pointer-events: none; 23 | font-size: 8px; 24 | position: absolute; 25 | width: 100%; 26 | height: 100%; 27 | top: 0; 28 | left: 0; 29 | overflow: visible; 30 | 31 | &:after { 32 | content: attr(title); 33 | color: invert($volume-primary); 34 | width: 5px; 35 | height: 5px; 36 | position: absolute; 37 | top: -15px; 38 | left: 50%; 39 | } 40 | 41 | @for $i from 1 through 11 { 42 | &:nth-child(#{$i}) { 43 | transform: rotate(-180 + $i * 30deg); 44 | } 45 | } 46 | } 47 | 48 | &__ctrl { 49 | position: absolute; 50 | width: 100%; 51 | height: 100%; 52 | border-radius: 50%; 53 | transform: rotate(0deg); 54 | transition: transform 0.5s; 55 | 56 | &:before { 57 | content: ""; 58 | position: absolute; 59 | bottom: 15%; 60 | left: 25%; 61 | width: 5%; 62 | height: 5%; 63 | background-color: invert($volume-primary); 64 | border-radius: 50%; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :musical_keyboard: Glimmer Synth 2 | 3 | [![Build Status](https://travis-ci.org/jimenglish81/glimmer-synth.svg?branch=master)](https://travis-ci.org/jimenglish81/glimmer-synth) 4 | [![dependencies Status](https://david-dm.org/jimenglish81/glimmer-synth/status.svg)](https://david-dm.org/jimenglish81/glimmer-synth) 5 | [![devDependencies Status](https://david-dm.org/jimenglish81/glimmer-synth/dev-status.svg)](https://david-dm.org/jimenglish81/glimmer-synth?type=dev) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | 8 | ``` 9 | _______________________________________ |--| 10 | | GLMR-SYNTH |... . | | o' o' 11 | | ::: |... .... . | /~~\ | |--| 12 | | ::: |.............| \__/ | o' o' 13 | |_______________________________________| 14 | | | | | | | | | | | | | | | | | | | 15 | | | | | | | | | | | | | | | | | | | 16 | | |_| |_| | |_| |_| |_| | |_| |_| | 17 | | | | | | | | | | | | 18 | |___|___|___|___|___|___|___|___|___|___| 19 | ``` 20 | 21 | Experiment to build a synthesizer with using WebAudio API and Glimmer. 22 | See it [here](https://glmr-synth.surge.sh). 23 | 24 | ## :musical_note: Prerequisites 25 | 26 | You will need the following things properly installed on your computer. 27 | 28 | * [Git](https://git-scm.com/) 29 | * [Node.js](https://nodejs.org/) (with NPM) 30 | * [Yarn](https://yarnpkg.com/en/) 31 | * [Ember CLI](https://ember-cli.com/) 32 | 33 | ## :musical_note: Installation 34 | 35 | * `git clone ` this repository 36 | * `cd glimmmer-synth` 37 | * `yarn` 38 | 39 | ## :musical_note: Running / Development 40 | 41 | * `ember serve` 42 | * Visit [http://localhost:4200](http://localhost:4200). 43 | -------------------------------------------------------------------------------- /src/ui/styles/synth-btn.scss: -------------------------------------------------------------------------------- 1 | $btn-shadow: #1C1C17; 2 | 3 | .synth-btn { 4 | background-color: #e6e6e6; 5 | background-image: 6 | -webkit-repeating-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 6%, rgba(255, 255, 255, 0.1) 7.5%), 7 | -webkit-repeating-linear-gradient(left, transparent 0%, transparent 4%, rgba(0, 0, 0, 0.03) 4.5%), 8 | -webkit-repeating-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0) 1.2%, rgba(255, 255, 255, 0.15) 2.2%), 9 | linear-gradient(180deg, #c7c7c7 0%, #e6e6e6 47%, #c7c7c7 53%, #b3b3b3 100%); 10 | border: none; 11 | border-radius: 0.5em; 12 | box-shadow: 13 | inset #262626 0 0px 0px 2px, 14 | inset rgba(38, 38, 38, 0.8) 0 -1px 5px 4px, 15 | inset rgba(0, 0, 0, 0.25) 0 -1px 0px 7px, 16 | inset rgba(255, 255, 255, 0.7) 0 2px 1px 7px; 17 | color: #333; 18 | cursor: pointer; 19 | display: block; 20 | font-size: 0.65em; 21 | height: 40px; 22 | outline: none; 23 | position: relative; 24 | text-align: center; 25 | text-shadow: 26 | rgba(102, 102, 102, 0.5) 0 -1px 0, 27 | rgba(255, 255, 255, 0.6) 0 2px 1px; 28 | text-transform: uppercase; 29 | width: 75px; 30 | 31 | &:before { 32 | background: #646464; 33 | border-radius: 50%; 34 | box-shadow: 0 0 4px $btn-shadow; 35 | content: ''; 36 | height: 6px; 37 | position: absolute; 38 | right: 10px; 39 | top: 10px; 40 | transition: all 0.5s ease; 41 | width: 6px; 42 | } 43 | 44 | &--play { 45 | &--active:before { 46 | background: #4fffa7; 47 | box-shadow: 48 | 0 0 4px $btn-shadow , 49 | 0 0 5px #42ffa1; 50 | } 51 | } 52 | 53 | &--record { 54 | &--active:before { 55 | background: #FF4F4F; 56 | box-shadow: 57 | 0 0 4px $btn-shadow , 58 | 0 0 5px #FF4242; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /workers/sw.js: -------------------------------------------------------------------------------- 1 | var CACHE_NAME = 'glimmer-synth-<@VERSION@>'; 2 | var ASSETS = [ 3 | '.', 4 | 'index.html', 5 | 'https://fonts.googleapis.com/css?family=Open+Sans:300|Gruppo:400|Roboto+Mono:400,800&subset=latin' 6 | ]; 7 | 8 | self.addEventListener('install', function(event) { 9 | event.waitUntil( 10 | caches.open(CACHE_NAME).then(function(cache) { 11 | return fetch('./asset-map.json') 12 | .then(function(res) { 13 | return res.ok ? res.json() : {}; 14 | }) 15 | .then(function(json) { 16 | var assets = json.assets || {}; 17 | cache.addAll( 18 | Object.keys(assets) 19 | .map(function(key) { 20 | return '/' + assets[key]; 21 | }) 22 | .concat(ASSETS) 23 | ); 24 | }); 25 | }) 26 | ); 27 | }); 28 | 29 | self.addEventListener('activate', function(event) { 30 | event.waitUntil( 31 | caches.keys().then(function(cacheNames) { 32 | return Promise.all( 33 | cacheNames 34 | .filter(function(cacheName) { 35 | return cacheName !== CACHE_NAME; 36 | }) 37 | .map(function(cacheName) { 38 | return caches.delete(cacheName); 39 | }) 40 | ); 41 | }) 42 | ); 43 | }); 44 | 45 | self.addEventListener('fetch', function(event) { 46 | var request = event.request; 47 | var requestUrl = new URL(request.url); 48 | event.respondWith( 49 | caches.open(CACHE_NAME).then(function(cache) { 50 | return cache.match(request).then(function(res) { 51 | if (res) { 52 | return res; 53 | } 54 | 55 | return fetch(request).then(function(networkRes) { 56 | if (/fonts\.gstatic\.com\//.test(requestUrl.href)) { 57 | cache.put(request, networkRes.clone()); 58 | } 59 | return networkRes; 60 | }); 61 | }); 62 | }) 63 | ); 64 | }); 65 | -------------------------------------------------------------------------------- /src/ui/components/synth-graphic/component.ts: -------------------------------------------------------------------------------- 1 | import Component, { tracked } from '@glimmer/component'; 2 | import { 3 | AudioService, 4 | default as audioService, 5 | } from '../../../utils/services/audio'; 6 | 7 | export default class SynthGraphic extends Component { 8 | public element: HTMLElement; 9 | private context: CanvasRenderingContext2D; 10 | 11 | constructor(options: object) { 12 | super(options); 13 | this._draw = this._draw.bind(this); 14 | } 15 | 16 | get audioService(): AudioService { 17 | return audioService; 18 | } 19 | 20 | get canvas(): HTMLCanvasElement { 21 | return this.element.querySelector('canvas'); 22 | } 23 | 24 | public didInsertElement(): void { 25 | const { args: { height, width }, canvas } = this; 26 | this.context = canvas.getContext('2d'); 27 | canvas.width = width; 28 | canvas.height = height; 29 | this._draw(); 30 | } 31 | 32 | private _draw(): void { 33 | const { 34 | args: { height, width }, 35 | audioService: service, 36 | canvas, 37 | context, 38 | } = this; 39 | const data = service.getAnalyserData(); 40 | const blockUnit = height / 10; 41 | 42 | context.fillStyle = '#1A1A1A'; 43 | context.fillRect(0, 0, width, height); 44 | 45 | for (let i = 0, l = data.length; i < l; i++) { 46 | const value = data[i]; 47 | const percent = value / 256; 48 | const barHeight = height * percent; 49 | let offset = height - barHeight; 50 | const barWidth = (width - l * 2) / l; 51 | const blocks = Math.ceil(height / blockUnit); 52 | 53 | for (let j = 0, k = blocks; j < k; j++) { 54 | const green = j / k * 75 + 100; 55 | context.fillStyle = `rgb(30,${green},30)`; 56 | context.fillRect(i * (barWidth + 2), offset, barWidth, blockUnit); 57 | offset += blockUnit + 0.5; 58 | } 59 | } 60 | 61 | requestAnimationFrame(this._draw); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/styles/synth-key.scss: -------------------------------------------------------------------------------- 1 | /*** Thanks to https://codepen.io/zastrow/pen/oDBki ***/ 2 | .synth-key { 3 | align-items: center; 4 | cursor: pointer; 5 | display: flex; 6 | justify-content: center; 7 | list-style: none; 8 | margin: 0; 9 | padding: 0; 10 | position: relative; 11 | 12 | &--white { 13 | background-image: linear-gradient(180deg, #eee 0%,#fff 100%); 14 | border-bottom: 1px solid #bbb; 15 | border-left: 1px solid #bbb; 16 | border-radius: 0 0 5px 5px; 17 | box-shadow: 18 | -1px 0 0 rgba(255,255,255,0.8) inset, 19 | 0 0 5px #ccc inset, 0 0 3px rgba(0,0,0,0.2); 20 | height: 256px; 21 | width: 7%; 22 | z-index: 1; 23 | } 24 | 25 | &--white--active { 26 | background-image: linear-gradient(180deg, #fff 0%,#e9e9e9 100%); 27 | border-bottom: 1px solid #999; 28 | border-left: 1px solid #999; 29 | border-top: 1px solid #777; 30 | box-shadow: 31 | 2px 0 3px rgba(0,0,0,0.1) inset, 32 | -5px 5px 20px rgba(0,0,0,0.2) inset, 33 | 0 0 3px rgba(0,0,0,0.2); 34 | } 35 | 36 | &--black { 37 | background-image: linear-gradient(45deg, #222 0%,#555 100%); 38 | border-radius: 0 0 3px 3px; 39 | box-shadow: 40 | -1px -1px 2px rgba(255,255,255,0.2) inset, 41 | 0 -5px 2px 3px rgba(0,0,0,0.6) inset, 42 | 0 2px 4px rgba(0,0,0,0.5); 43 | height: 160px; 44 | margin: 0 0 0 -1em; 45 | width: 3.5%; 46 | z-index: 2; 47 | } 48 | 49 | &--black--active { 50 | background-image: linear-gradient(0deg, #444 0%,#222 100%); 51 | box-shadow: 52 | -1px -1px 2px rgba(255,255,255,0.2) inset, 53 | 0 -2px 2px 3px rgba(0,0,0,0.6) inset, 54 | 0 1px 2px rgba(0,0,0,0.5); 55 | } 56 | 57 | &--indent { 58 | margin: 0 0 0 -1.6%; 59 | } 60 | 61 | &:first-child { 62 | border-radius: 0 0 5px 5px; 63 | } 64 | 65 | &:last-child { 66 | border-radius: 0 0 5px 5px; 67 | } 68 | 69 | &__help { 70 | @include help-tip() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ui/components/synth-volume/component-test.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import hbs from '@glimmer/inline-precompile'; 3 | import { setupRenderingTest } from '@glimmer/test-helpers'; 4 | 5 | const { module, test } = QUnit; 6 | 7 | module('Component: synth-volume', function(hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | test('it renders correctly', async function(assert) { 11 | await this.render(hbs``); 12 | 13 | assert.ok(this.containerElement.querySelector('.synth-volume')); 14 | 15 | assert.ok(this.containerElement.querySelector('.synth-volume__ctrl')); 16 | 17 | assert.equal( 18 | this.containerElement 19 | .querySelector('.synth-volume__ctrl') 20 | .style.transform.match(/[0-9]+/)[0], 21 | '0' 22 | ); 23 | 24 | assert.equal( 25 | this.containerElement.querySelectorAll('.synth-volume__tick').length, 26 | 11 27 | ); 28 | 29 | [...Array(11).keys()].forEach(n => 30 | assert.equal( 31 | this.containerElement.querySelector( 32 | `.synth-volume__tick:nth-child(${n + 1})` 33 | ).title, 34 | `${n}` 35 | ) 36 | ); 37 | }); 38 | 39 | test('it rotates volume dial correctly', async function(assert) { 40 | await this.render(hbs``); 41 | 42 | assert.equal( 43 | parseInt( 44 | this.containerElement 45 | .querySelector('.synth-volume__ctrl') 46 | .style.transform.match(/[0-9]+/)[0], 47 | 10 48 | ), 49 | 60 50 | ); 51 | }); 52 | 53 | test('it does not rotate volume dial beyond max value', async function( 54 | assert 55 | ) { 56 | await this.render(hbs``); 57 | 58 | assert.equal( 59 | parseInt( 60 | this.containerElement 61 | .querySelector('.synth-volume__ctrl') 62 | .style.transform.match(/[0-9]+/)[0], 63 | 10 64 | ), 65 | 300 66 | ); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glimmer-synth", 3 | "version": "0.5.0", 4 | "description": "Glimmer synthesizer using WebAudio API.", 5 | "directories": { 6 | "doc": "doc", 7 | "test": "tests" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/jimenglish81/glimmer-synth.git" 12 | }, 13 | "keywords": [ 14 | "glimmerjs", 15 | "synth", 16 | "WebAudio" 17 | ], 18 | "author": "Jim English", 19 | "bugs": { 20 | "url": "https://github.com/jimenglish81/glimmer-synth/issues" 21 | }, 22 | "homepage": "https://github.com/jimenglish81/glimmer-synth#readme", 23 | "scripts": { 24 | "build": "ember build -e production", 25 | "precommit": "lint-staged", 26 | "start": "ember server", 27 | "test": "ember test", 28 | "lint": "tslint -c tslint.json 'src/**/*.ts'" 29 | }, 30 | "lint-staged": { 31 | "src/**/*.ts": [ 32 | "prettier --single-quote --trailing-comma es5 --write", 33 | "git add" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "@glimmer/application": "^0.7.2", 38 | "@glimmer/application-pipeline": "^0.8.0", 39 | "@glimmer/blueprint": "^0.5.0", 40 | "@glimmer/inline-precompile": "^1.0.0", 41 | "@glimmer/resolver": "^0.3.0", 42 | "@glimmer/test-helpers": "^0.30.0", 43 | "@types/qunit": "^2.0.31", 44 | "broccoli-asset-rev": "^2.5.0", 45 | "broccoli-funnel": "^1.2.0", 46 | "broccoli-merge-trees": "^2.0.0", 47 | "broccoli-stew": "^1.5.0", 48 | "broccoli-string-replace": "^0.1.2", 49 | "ember-cli": "^2.14.0", 50 | "ember-cli-dependency-checker": "^2.0.1", 51 | "ember-cli-inject-live-reload": "^1.6.1", 52 | "ember-cli-sass": "^6.2.0", 53 | "ember-cli-uglify": "^2.0.0-beta.1", 54 | "husky": "^0.14.3", 55 | "lint-staged": "^4.0.2", 56 | "prettier": "^1.5.3", 57 | "qunitjs": "^2.3.3", 58 | "rollup-plugin-commonjs": "^8.1.0", 59 | "sinon": "^2.4.1", 60 | "surge": "^0.19.0", 61 | "tslint": "^5.5.0", 62 | "tslint-config-prettier": "^1.3.0", 63 | "typescript": "^2.2.2" 64 | }, 65 | "engines": { 66 | "node": ">= 6.0" 67 | }, 68 | "private": true, 69 | "dependencies": { 70 | "rxjs": "^5.4.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/note.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: object-literal-sort-keys */ 2 | export const semitoneMap = { 3 | c: -9, 4 | d: -7, 5 | e: -5, 6 | f: -4, 7 | g: -2, 8 | a: 0, 9 | b: 2, 10 | }; 11 | /* tslint:enable: object-literal-sort-keys */ 12 | 13 | export const calculateSteps = (note: string, octave: number): number => 14 | (4 - octave) * -12 + semitoneMap[note]; 15 | 16 | export const calculateFrequency = (semitones: number, base: number): number => 17 | base * Math.pow(Math.pow(2, 1 / 12), semitones); 18 | 19 | export class Note { 20 | public context: AudioContext; 21 | public destination: GainNode; 22 | public frequency: number; 23 | public isPlaying: boolean; 24 | public primary: OscillatorNode | null; 25 | public secondary: OscillatorNode | null; 26 | 27 | constructor( 28 | note: string, 29 | octave: number, 30 | context: AudioContext, 31 | destination: GainNode 32 | ) { 33 | const [pitch, symbol] = note.split(''); 34 | const semitones = calculateSteps(pitch, octave) + (symbol ? 1 : 0); 35 | this.frequency = calculateFrequency(semitones, 440.0); 36 | this.context = context; 37 | this.destination = destination; 38 | this.isPlaying = false; 39 | } 40 | 41 | public start(): void { 42 | const { context, destination, frequency, isPlaying } = this; 43 | if (!isPlaying) { 44 | const primary = context.createOscillator(); 45 | const secondary = context.createOscillator(); 46 | primary.connect(destination); 47 | secondary.connect(destination); 48 | primary.type = 'sawtooth'; 49 | primary.detune.value = -4; 50 | primary.frequency.value = frequency; 51 | secondary.type = 'triangle'; 52 | secondary.detune.value = 4; 53 | secondary.frequency.value = frequency; 54 | 55 | primary.start(0); 56 | secondary.start(0); 57 | this.isPlaying = true; 58 | 59 | this.primary = primary; 60 | this.secondary = secondary; 61 | } 62 | } 63 | 64 | public stop(): void { 65 | const { isPlaying, primary, secondary } = this; 66 | if (isPlaying) { 67 | primary.stop(0); 68 | secondary.stop(0); 69 | primary.disconnect(); 70 | secondary.disconnect(); 71 | this.primary = null; 72 | this.secondary = null; 73 | this.isPlaying = false; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/ui/components/synth-ctrl/component.ts: -------------------------------------------------------------------------------- 1 | import Component, { tracked } from '@glimmer/component'; 2 | import * as Rx from 'rxjs/Rx'; 3 | import { 4 | AudioService, 5 | default as audioService, 6 | VOLUME_INCREMENT, 7 | } from '../../../utils/services/audio'; 8 | import { default as keyService, KeyService } from '../../../utils/services/key'; 9 | 10 | const DEC: string = '-'; 11 | const INC: string = '+'; 12 | const PLAY: string = '2'; 13 | const RECORD: string = '1'; 14 | 15 | const convertVolume = (volume: number): number => volume / VOLUME_INCREMENT; 16 | 17 | export default class SynthCtrl extends Component { 18 | @tracked public isPlaying: boolean = false; 19 | @tracked public isRecording: boolean = false; 20 | @tracked public volume: number; 21 | private keySub: Rx.Subscription; 22 | 23 | constructor(options) { 24 | super(options); 25 | this._handleKeyPress = this._handleKeyPress.bind(this); 26 | } 27 | 28 | get audioService(): AudioService { 29 | return audioService; 30 | } 31 | 32 | get keyService(): KeyService { 33 | return keyService; 34 | } 35 | 36 | get supportsRecording(): boolean { 37 | return AudioService.supportsRecording; 38 | } 39 | 40 | public decrement(): void { 41 | this.audioService.decrementVolume(); 42 | this.volume = convertVolume(this.audioService.volume); 43 | } 44 | 45 | public increment(): void { 46 | this.audioService.incrementVolume(); 47 | this.volume = convertVolume(this.audioService.volume); 48 | } 49 | 50 | public play(): void { 51 | const { audioService: service, isPlaying } = this; 52 | if (isPlaying) { 53 | service.audio.pause(); 54 | this.isPlaying = false; 55 | } else { 56 | if (service.hasAudioRecording) { 57 | service.audio.play(); 58 | this.isPlaying = true; 59 | } 60 | } 61 | } 62 | 63 | public record(): void { 64 | const { audioService: service, isRecording } = this; 65 | if (isRecording) { 66 | service.stopRecording(); 67 | this.isRecording = false; 68 | } else { 69 | service.startRecording(); 70 | this.isRecording = true; 71 | } 72 | } 73 | 74 | public didInsertElement(): void { 75 | const { audioService: { volume }, keyService: { keyup } } = this; 76 | this.volume = convertVolume(volume); 77 | this.keySub = keyup 78 | .filter(({ key }) => [DEC, INC, PLAY, RECORD].indexOf(key) > -1) 79 | .subscribe(this._handleKeyPress); 80 | } 81 | 82 | public willDestroy(): void { 83 | this.keySub.unsubscribe(); 84 | } 85 | 86 | private _handleKeyPress({ key }): void { 87 | switch (key) { 88 | case DEC: 89 | this.decrement(); 90 | break; 91 | case INC: 92 | this.increment(); 93 | break; 94 | case PLAY: 95 | this.play(); 96 | break; 97 | case RECORD: 98 | this.record(); 99 | break; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/note-test.ts: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import { Note } from './note'; 3 | 4 | const { module, test } = QUnit; 5 | // const mockOscillator = () => ({ 6 | // type: '', 7 | // detune: { 8 | // value: 0, 9 | // }, 10 | // frequency: { 11 | // value: 0, 12 | // }, 13 | // connect: sinon.spy(), 14 | // disconnect: sinon.spy(), 15 | // start: sinon.spy(), 16 | // stop: sinon.spy(), 17 | // }); 18 | 19 | module('Util: Note', function(hook) { 20 | test('it can be created', function(assert) { 21 | const context: AudioContext = new AudioContext(); 22 | const gain: GainNode = context.createGain(); 23 | const note: Note = new Note('c', 4, context, gain); 24 | 25 | assert.equal(note.isPlaying, false); 26 | assert.equal(note.context, context); 27 | assert.equal(note.destination, gain); 28 | assert.equal(Math.floor(note.frequency), 261); 29 | }); 30 | 31 | // test('it can start note', function(assert) { 32 | // const context: AudioContext = new AudioContext(); 33 | // const gain: GainNode = context.createGain(); 34 | // const note: Note = new Note('c', 4, context, gain); 35 | // const primaryMock = mockOscillator(); 36 | // const secondaryMock = mockOscillator(); 37 | // 38 | // context.createOscillator = sinon.stub() 39 | // .onFirstCall().returns(primaryMock) 40 | // .onSecondCall().returns(secondaryMock); 41 | // 42 | // note.start(); 43 | // 44 | // assert.equal(note.isPlaying, true); 45 | // assert.equal(note.primary, primaryMock); 46 | // assert.equal(note.secondary, secondaryMock); 47 | // 48 | // assert.equal(context.createOscillator.callCount, 2); 49 | // 50 | // assert.equal(primaryMock.type, 'sawtooth'); 51 | // assert.equal(primaryMock.detune.value, -4); 52 | // assert.equal(Math.floor(primaryMock.frequency.value), 261); 53 | // assert.ok(primaryMock.connect.calledWith(gain)); 54 | // assert.ok(primaryMock.start.calledWith(0)); 55 | // 56 | // assert.equal(secondaryMock.type, 'triangle'); 57 | // assert.equal(secondaryMock.detune.value, -4); 58 | // assert.equal(Math.floor(secondaryMock.frequency.value), 261); 59 | // assert.ok(secondaryMock.connect.calledWith(gain)); 60 | // assert.ok(secondaryMock.start.calledWith(0)); 61 | // }); 62 | 63 | // test('it can stop note', function(assert) { 64 | // const context: AudioContext = new AudioContext(); 65 | // const gain: GainNode = context.createGain(); 66 | // const note: Note = new Note('c', 4, context, gain); 67 | // const primaryMock = mockOscillator(); 68 | // const secondaryMock = mockOscillator(); 69 | // 70 | // note.isPlaying = true; 71 | // note.primary = primaryMock; 72 | // note.secondary = secondaryMock; 73 | // 74 | // note.stop(); 75 | // 76 | // assert.equal(note.isPlaying, false); 77 | // assert.equal(note.primary, null); 78 | // assert.equal(note.secondary, null); 79 | // 80 | // assert.ok(primaryMock.stop.calledWith(0)); 81 | // assert.equal(primaryMock.disconnect.callCount, 1); 82 | // 83 | // assert.ok(secondaryMock.stop.calledWith(0)); 84 | // assert.equal(secondaryMock.disconnect.callCount, 1); 85 | // }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/ui/components/synth-keyboard/component.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | 3 | export interface IKey { 4 | name: string; 5 | octave: number; 6 | color: string; 7 | indent: boolean; 8 | shortcut: string; 9 | } 10 | 11 | export const KEYS: IKey[] = [ 12 | { 13 | color: 'white', 14 | indent: true, 15 | name: 'c', 16 | octave: 4, 17 | shortcut: 'a', 18 | }, 19 | { 20 | color: 'black', 21 | indent: false, 22 | name: 'c#', 23 | octave: 4, 24 | shortcut: 'q', 25 | }, 26 | { 27 | color: 'white', 28 | indent: true, 29 | name: 'd', 30 | octave: 4, 31 | shortcut: 's', 32 | }, 33 | { 34 | color: 'black', 35 | indent: false, 36 | name: 'd#', 37 | octave: 4, 38 | shortcut: 'w', 39 | }, 40 | { 41 | color: 'white', 42 | indent: true, 43 | name: 'e', 44 | octave: 4, 45 | shortcut: 'd', 46 | }, 47 | { 48 | color: 'white', 49 | indent: false, 50 | name: 'f', 51 | octave: 4, 52 | shortcut: 'f', 53 | }, 54 | { 55 | color: 'black', 56 | indent: false, 57 | name: 'f#', 58 | octave: 4, 59 | shortcut: 'e', 60 | }, 61 | { 62 | color: 'white', 63 | indent: true, 64 | name: 'g', 65 | octave: 4, 66 | shortcut: 'g', 67 | }, 68 | { 69 | color: 'black', 70 | indent: false, 71 | name: 'g#', 72 | octave: 4, 73 | shortcut: 'r', 74 | }, 75 | { 76 | color: 'white', 77 | indent: true, 78 | name: 'a', 79 | octave: 4, 80 | shortcut: 'h', 81 | }, 82 | { 83 | color: 'black', 84 | indent: false, 85 | name: 'a#', 86 | octave: 4, 87 | shortcut: 't', 88 | }, 89 | { 90 | color: 'white', 91 | indent: true, 92 | name: 'b', 93 | octave: 4, 94 | shortcut: 'j', 95 | }, 96 | { 97 | color: 'white', 98 | indent: false, 99 | name: 'c', 100 | octave: 5, 101 | shortcut: 'k', 102 | }, 103 | { 104 | color: 'black', 105 | indent: false, 106 | name: 'c#', 107 | octave: 5, 108 | shortcut: 'y', 109 | }, 110 | { 111 | color: 'white', 112 | indent: true, 113 | name: 'd', 114 | octave: 5, 115 | shortcut: 'l', 116 | }, 117 | { 118 | color: 'black', 119 | indent: false, 120 | name: 'd#', 121 | octave: 5, 122 | shortcut: 'u', 123 | }, 124 | { 125 | color: 'white', 126 | indent: true, 127 | name: 'e', 128 | octave: 5, 129 | shortcut: ';', 130 | }, 131 | { 132 | color: 'white', 133 | indent: false, 134 | name: 'f', 135 | octave: 5, 136 | shortcut: `'`, 137 | }, 138 | { 139 | color: 'black', 140 | indent: false, 141 | name: 'f#', 142 | octave: 5, 143 | shortcut: 'i', 144 | }, 145 | { 146 | color: 'white', 147 | indent: true, 148 | name: 'g', 149 | octave: 5, 150 | shortcut: '`', 151 | }, 152 | { 153 | color: 'black', 154 | indent: false, 155 | name: 'g#', 156 | octave: 5, 157 | shortcut: 'o', 158 | }, 159 | { 160 | color: 'white', 161 | indent: true, 162 | name: 'a', 163 | octave: 5, 164 | shortcut: 'z', 165 | }, 166 | { 167 | color: 'black', 168 | indent: false, 169 | name: 'a#', 170 | octave: 5, 171 | shortcut: 'p', 172 | }, 173 | { 174 | color: 'white', 175 | indent: true, 176 | name: 'b', 177 | octave: 5, 178 | shortcut: 'x', 179 | }, 180 | { 181 | color: 'white', 182 | indent: false, 183 | name: 'c', 184 | octave: 6, 185 | shortcut: 'c', 186 | }, 187 | { 188 | color: 'black', 189 | indent: false, 190 | name: 'c#', 191 | octave: 6, 192 | shortcut: '[', 193 | }, 194 | { 195 | color: 'white', 196 | indent: true, 197 | name: 'd', 198 | octave: 6, 199 | shortcut: 'v', 200 | }, 201 | ]; 202 | 203 | export default class SynthKeyboard extends Component { 204 | public keys: IKey[] = KEYS; 205 | } 206 | -------------------------------------------------------------------------------- /src/utils/services/audio.ts: -------------------------------------------------------------------------------- 1 | import { Note } from '../note'; 2 | 3 | export const VOLUME_MAX: number = 1.5; 4 | export const VOLUME_INCREMENT: number = VOLUME_MAX / 10; 5 | 6 | /* tslint:disable */ 7 | const AudioCtx = window.AudioContext || window.webkitAudioContext; 8 | const noop = () => {}; 9 | /* tslint:enable */ 10 | 11 | export class AudioService { 12 | public audio: HTMLAudioElement; 13 | public context: AudioContext; 14 | public hasAudioRecording: boolean; 15 | public volume: number; 16 | private analyser: AnalyserNode; 17 | private analyserFreqs: Uint8Array; 18 | private audioData: Blob[]; 19 | private effect: ConvolverNode; 20 | private masterVolume: GainNode; 21 | private mediaRecorder: MediaRecorder; 22 | 23 | constructor() { 24 | this.context = new AudioCtx(); 25 | this.audio = new Audio(); 26 | this.audioData = []; 27 | this.hasAudioRecording = false; 28 | this.volume = VOLUME_INCREMENT * 2; 29 | } 30 | 31 | public static get supportsAudio(): boolean { 32 | return !!AudioCtx; 33 | } 34 | 35 | public static get supportsRecording(): boolean { 36 | return !!window.MediaStreamAudioDestinationNode; 37 | } 38 | 39 | public createNote(note: string, octave: number): Note { 40 | this.setUp(); 41 | return new Note(note, octave, this.context, this.masterVolume); 42 | } 43 | 44 | public decrementVolume(): void { 45 | this.setUp(); 46 | const { masterVolume } = this; 47 | const newValue = masterVolume.gain.value - VOLUME_INCREMENT; 48 | this.volume = masterVolume.gain.value = Math.max(newValue, 0); 49 | } 50 | 51 | public getAnalyserData(): Uint8Array { 52 | const { analyser, analyserFreqs = new Uint8Array(0) } = this; 53 | if (analyser) { 54 | analyser.getByteFrequencyData(analyserFreqs); 55 | } 56 | return analyserFreqs; 57 | } 58 | 59 | public incrementVolume(): void { 60 | this.setUp(); 61 | const { masterVolume } = this; 62 | const newValue = masterVolume.gain.value + VOLUME_INCREMENT; 63 | this.volume = masterVolume.gain.value = Math.min(newValue, VOLUME_MAX); 64 | } 65 | 66 | public startRecording(): void { 67 | this.setUp(); 68 | const { context, mediaRecorder, masterVolume } = this; 69 | this.audioData = []; 70 | const silence = context.createBufferSource(); 71 | silence.connect(masterVolume); 72 | mediaRecorder.start(0); 73 | } 74 | 75 | public stopRecording(): void { 76 | this.setUp(); 77 | this.mediaRecorder.stop(); 78 | } 79 | 80 | private setUp(): void { 81 | this.setUpVolume(); 82 | this.setUpEffect(); 83 | this.setUpMediaRecorder(); 84 | this.setUpAnalyser(); 85 | 86 | this.setUp = noop; 87 | } 88 | 89 | private setUpAnalyser(): void { 90 | const { context, effect, masterVolume } = this; 91 | const analyser = context.createAnalyser(); 92 | analyser.fftSize = 64; 93 | const analyserFreqs = new Uint8Array(analyser.frequencyBinCount); 94 | analyser.connect(context.destination); 95 | masterVolume.connect(analyser); 96 | effect.connect(analyser); 97 | this.analyser = analyser; 98 | this.analyserFreqs = analyserFreqs; 99 | } 100 | 101 | private setUpEffect(): void { 102 | // create white noise. 103 | const { context, masterVolume } = this; 104 | const { sampleRate } = context; 105 | const convolver = context.createConvolver(); 106 | const buffer = context.createBuffer(2, 0.5 * sampleRate, sampleRate); 107 | const left = buffer.getChannelData(0); 108 | const right = buffer.getChannelData(1); 109 | for (let i = 0; i < buffer.length; i++) { 110 | left[i] = Math.random() * 6 - 1; 111 | right[i] = Math.random() * 6 - 1; 112 | } 113 | convolver.buffer = buffer; 114 | convolver.connect(context.destination); 115 | this.masterVolume.connect(convolver); 116 | this.effect = convolver; 117 | } 118 | 119 | private setUpMediaRecorder(): void { 120 | const { audio, audioData, context, effect, masterVolume } = this; 121 | if (AudioService.supportsRecording) { 122 | const dest = context.createMediaStreamDestination(); 123 | const mediaRecorder = new MediaRecorder(dest.stream); 124 | mediaRecorder.ignoreMutedMedia = false; 125 | 126 | mediaRecorder.ondataavailable = ({ data }) => audioData.push(data); 127 | 128 | mediaRecorder.onstop = () => { 129 | const blob = new Blob(audioData, { 130 | type: 'audio/ogg; codecs=opus', 131 | }); 132 | audio.src = URL.createObjectURL(blob); 133 | audio.loop = true; 134 | this.hasAudioRecording = true; 135 | }; 136 | 137 | masterVolume.connect(dest); 138 | effect.connect(dest); 139 | this.mediaRecorder = mediaRecorder; 140 | } 141 | } 142 | 143 | private setUpVolume(): void { 144 | const masterVolume = this.context.createGain(); 145 | masterVolume.gain.value = this.volume; 146 | masterVolume.connect(this.context.destination); 147 | this.masterVolume = masterVolume; 148 | } 149 | } 150 | 151 | export default new AudioService(); 152 | --------------------------------------------------------------------------------