├── .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 |
2 | {{#each keys key="@index" as |key|}}
3 |
4 | {{/each}}
5 |
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 |
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 |
GLMR-Synth
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 | [](https://travis-ci.org/jimenglish81/glimmer-synth)
4 | [](https://david-dm.org/jimenglish81/glimmer-synth)
5 | [](https://david-dm.org/jimenglish81/glimmer-synth?type=dev)
6 | [](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 |
--------------------------------------------------------------------------------