├── .prettierrc
├── .browserslistrc
├── public
├── robots.txt
├── click.wav
├── favicon.ico
├── click-accent.wav
├── img
│ └── icons
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── mstile-150x150.png
│ │ ├── apple-touch-icon.png
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-256x256.png
│ │ ├── apple-touch-icon-60x60.png
│ │ ├── apple-touch-icon-76x76.png
│ │ ├── apple-touch-icon-120x120.png
│ │ ├── apple-touch-icon-152x152.png
│ │ ├── apple-touch-icon-180x180.png
│ │ └── msapplication-icon-144x144.png
├── manifest.json
└── index.html
├── babel.config.js
├── screenshot.png
├── postcss.config.js
├── src
├── plugins
│ └── vuetify.js
├── key.js
├── main.js
├── database.js
├── views
│ └── Home.vue
├── router.js
├── tracks.js
├── components
│ ├── TextField.vue
│ ├── AboutDialog.vue
│ ├── TrackManager.vue
│ ├── Clock.vue
│ ├── Track.vue
│ ├── SettingsDialog.vue
│ └── Controls.vue
├── registerServiceWorker.js
├── midi.js
├── App.vue
├── Track.js
├── click.js
└── store
│ ├── settings.js
│ └── index.js
├── .gitignore
├── .eslintrc.js
├── vue.config.js
├── README.md
├── LICENSE
└── package.json
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
--------------------------------------------------------------------------------
/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
3 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ["@vue/app"]
3 | };
4 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/screenshot.png
--------------------------------------------------------------------------------
/public/click.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/click.wav
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {}
4 | }
5 | };
6 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/click-accent.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/click-accent.wav
--------------------------------------------------------------------------------
/public/img/icons/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/favicon-16x16.png
--------------------------------------------------------------------------------
/public/img/icons/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/favicon-32x32.png
--------------------------------------------------------------------------------
/public/img/icons/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/mstile-150x150.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/img/icons/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/img/icons/android-chrome-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/android-chrome-256x256.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/apple-touch-icon-60x60.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/apple-touch-icon-76x76.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/public/img/icons/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/public/img/icons/msapplication-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dhulme/multitrack-player/HEAD/public/img/icons/msapplication-icon-144x144.png
--------------------------------------------------------------------------------
/src/plugins/vuetify.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Vuetify from 'vuetify/lib';
3 |
4 | Vue.use(Vuetify);
5 |
6 | export default new Vuetify({
7 | icons: {
8 | iconfont: 'mdiSvg'
9 | }
10 | });
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 |
9 | # Log files
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 |
14 | # Editor directories and files
15 | .idea
16 | .vscode
17 | *.suo
18 | *.ntvs*
19 | *.njsproj
20 | *.sln
21 | *.sw?
22 |
--------------------------------------------------------------------------------
/src/key.js:
--------------------------------------------------------------------------------
1 | export function initKeyEvents(store) {
2 | document.addEventListener('keyup', event => {
3 | store.dispatch(
4 | store.state.controlEditMode ? 'setControlEdit' : 'triggerControlAction',
5 | {
6 | type: 'key',
7 | value: event.key
8 | }
9 | );
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import App from './App.vue';
3 | import router from './router';
4 | import store from './store';
5 | import './registerServiceWorker';
6 | import vuetify from './plugins/vuetify';
7 |
8 | Vue.config.productionTip = false;
9 |
10 | new Vue({
11 | router,
12 | store,
13 | vuetify,
14 | render: h => h(App)
15 | }).$mount('#app');
16 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true
5 | },
6 | extends: ["plugin:vue/essential", "@vue/prettier"],
7 | rules: {
8 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off",
9 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off"
10 | },
11 | parserOptions: {
12 | parser: "babel-eslint"
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/src/database.js:
--------------------------------------------------------------------------------
1 | import { openDB } from 'idb';
2 |
3 | const defaultCollection = 'default';
4 | const dbConnection = openDB('multitrack-player', 1, {
5 | upgrade(db) {
6 | db.createObjectStore(defaultCollection);
7 | }
8 | });
9 |
10 | export async function set(key, value, collection = defaultCollection) {
11 | return (await dbConnection).put(collection, value, key);
12 | }
13 |
14 | export async function get(key, collection = defaultCollection) {
15 | return (await dbConnection).get(collection, key);
16 | }
17 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "multitrack-player",
3 | "short_name": "multitrack-player",
4 | "icons": [
5 | {
6 | "src": "./img/icons/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "./img/icons/android-chrome-256x256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }
15 | ],
16 | "start_url": "./index.html",
17 | "display": "standalone",
18 | "background_color": "#fff",
19 | "theme_color": "#fff"
20 | }
21 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const packageJson = fs.readFileSync('./package.json');
3 | const version = JSON.parse(packageJson).version || 0;
4 | const webpack = require('webpack');
5 |
6 | module.exports = {
7 | configureWebpack: {
8 | plugins: [
9 | new webpack.DefinePlugin({
10 | 'process.env': {
11 | PACKAGE_VERSION: '"' + version + '"'
12 | }
13 | })
14 | ]
15 | },
16 | lintOnSave: false,
17 | productionSourceMap: false,
18 | publicPath: '/multitrack-player/'
19 | };
20 |
--------------------------------------------------------------------------------
/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
28 |
--------------------------------------------------------------------------------
/src/router.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Router from 'vue-router';
3 | import Home from './views/Home.vue';
4 |
5 | Vue.use(Router);
6 |
7 | export default new Router({
8 | routes: [
9 | {
10 | path: '/',
11 | name: 'home',
12 | component: Home
13 | }
14 | // {
15 | // path: '/about',
16 | // name: 'about',
17 | // // route level code-splitting
18 | // // this generates a separate chunk (about.[hash].js) for this route
19 | // // which is lazy-loaded when the route is visited.
20 | // component: () =>
21 | // import(/* webpackChunkName: "about" */ './views/About.vue')
22 | // }
23 | ]
24 | });
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Multitrack Player
2 |
3 | https://dhulme.uk/multitrack-player/
4 |
5 | A multitrack audio file player for the browser.
6 |
7 | Built using Vue, the Vuetify UI framework and peaks.js audio waveform library.
8 |
9 | ## Current features
10 | 🎚️ Unlimited number of audio tracks
11 | 🕰️ Metronome/click
12 | 🎛️ Customizable panning of click/tracks
13 | 🔇 Solo/mute for all tracks
14 | 🔊 Individual track and master gain control
15 | 🎹 Customisable MIDI and keyboard control
16 |
17 | 
18 |
19 | ## Local development setup
20 |
21 | ### Compiles and hot-reloads for development
22 | `npm start` or `npm run server`
23 |
24 | ### Compiles and minifies for production
25 | `npm run build`
26 |
--------------------------------------------------------------------------------
/src/tracks.js:
--------------------------------------------------------------------------------
1 | import Track from './Track';
2 |
3 | export const tracksAudioContext = new AudioContext();
4 | export const tracksStereoPannerNode = new StereoPannerNode(tracksAudioContext, {
5 | pan: 0
6 | });
7 |
8 | export function setTrackGain(track, settingsState, rootState) {
9 | track.setGain(settingsState.trackGainValue, rootState.soloTrack);
10 | }
11 |
12 | export function newTrack({ name, arrayBuffer }) {
13 | const track = new Track({
14 | name,
15 | audioContext: tracksAudioContext,
16 | stereoPannerNode: tracksStereoPannerNode
17 | });
18 | track.init(arrayBuffer);
19 | return track;
20 | }
21 |
22 | export function playTracks(tracks, position) {
23 | tracks.forEach(track => track.play(tracksAudioContext.currentTime, position));
24 | }
25 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Multitrack Player
9 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/components/TextField.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
35 |
36 |
44 |
--------------------------------------------------------------------------------
/src/components/AboutDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | About
5 |
6 | Created by David Hulme
7 |
8 | github.com/dhulme/multitrack-player
11 | dhulme.uk
12 | @hulmed
13 |
14 | Version {{ version }}
15 |
16 |
17 |
18 |
19 |
20 |
39 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | import { register } from 'register-service-worker';
4 |
5 | if (process.env.NODE_ENV === 'production') {
6 | register(`${process.env.BASE_URL}service-worker.js`, {
7 | ready() {
8 | console.log(
9 | 'App is being served from cache by a service worker.\n' +
10 | 'For more details, visit https://goo.gl/AFskqB'
11 | );
12 | },
13 | registered() {
14 | console.log('Service worker has been registered.');
15 | },
16 | cached() {
17 | console.log('Content has been cached for offline use.');
18 | },
19 | updatefound() {
20 | console.log('New content is downloading.');
21 | },
22 | updated() {
23 | console.log('New content is available; please refresh.');
24 | },
25 | offline() {
26 | console.log(
27 | 'No internet connection found. App is running in offline mode.'
28 | );
29 | },
30 | error(error) {
31 | console.error('Error during service worker registration:', error);
32 | }
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Dave Hulme
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/midi.js:
--------------------------------------------------------------------------------
1 | import webMidi from 'webmidi';
2 |
3 | export function initMidi() {
4 | return new Promise((resolve, reject) => {
5 | webMidi.enable(err => {
6 | if (err) {
7 | reject(err);
8 | } else {
9 | resolve();
10 | }
11 | });
12 | });
13 | }
14 |
15 | export function inputs() {
16 | return webMidi.inputs;
17 | }
18 |
19 | export function getInput(name) {
20 | return webMidi.getInputByName(name);
21 | }
22 |
23 | export function initMidiEvents(deviceName, store) {
24 | const input = getInput(deviceName);
25 |
26 | input.removeListener();
27 | input.addListener('noteon', 'all', event => {
28 | store.dispatch(
29 | store.rootState.controlEditMode
30 | ? 'setControlEdit'
31 | : 'triggerControlAction',
32 | { type: 'note', value: event.note.number }
33 | );
34 | });
35 | input.addListener('controlchange', 'all', event => {
36 | if (event.value !== 127) {
37 | return;
38 | }
39 | store.dispatch(
40 | store.rootState.controlEditMode
41 | ? 'setControlEdit'
42 | : 'triggerControlAction',
43 | { type: 'controlChange', value: event.controller.number }
44 | );
45 | });
46 | }
47 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Multitrack Player
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
44 |
45 |
50 |
--------------------------------------------------------------------------------
/src/components/TrackManager.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
17 |
18 |
19 |
20 |
54 |
--------------------------------------------------------------------------------
/src/components/Clock.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ icon }}
4 |
5 |
11 |
16 | :
17 |
18 |
19 |
20 |
21 |
22 |
42 |
43 |
59 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "multitrack-player",
3 | "author": {
4 | "name": "David Hulme",
5 | "url": "https://dhulme.uk"
6 | },
7 | "version": "1.0.0",
8 | "private": true,
9 | "scripts": {
10 | "serve": "vue-cli-service serve",
11 | "build": "vue-cli-service build",
12 | "start": "vue-cli-service serve"
13 | },
14 | "dependencies": {
15 | "@mdi/js": "^5.2.45",
16 | "core-js": "^3.6.5",
17 | "idb": "^5.0.3",
18 | "register-service-worker": "^1.6.2",
19 | "vue": "^2.6.10",
20 | "vue-router": "^3.0.3",
21 | "vuetify": "^2.2.28",
22 | "vuex": "^3.0.1",
23 | "wavesurfer.js": "^3.3.3",
24 | "webmidi": "^2.5.1"
25 | },
26 | "devDependencies": {
27 | "@vue/cli-plugin-babel": "^4.3.1",
28 | "@vue/cli-plugin-eslint": "^4.3.1",
29 | "@vue/cli-plugin-pwa": "^4.3.1",
30 | "@vue/cli-service": "^4.3.1",
31 | "@vue/eslint-config-prettier": "^5.0.0",
32 | "babel-eslint": "^10.0.1",
33 | "eslint": "^5.16.0",
34 | "eslint-plugin-prettier": "^3.1.0",
35 | "eslint-plugin-vue": "^5.0.0",
36 | "lint-staged": "^8.1.5",
37 | "prettier": "^1.18.2",
38 | "sass": "^1.17.4",
39 | "sass-loader": "^8.0.2",
40 | "vue-cli-plugin-vuetify": "^2.0.5",
41 | "vue-template-compiler": "^2.6.10",
42 | "vuetify-loader": "^1.4.3"
43 | },
44 | "gitHooks": {
45 | "pre-commit": "lint-staged"
46 | },
47 | "lint-staged": {
48 | "*.{js,vue}": [
49 | "vue-cli-service lint",
50 | "git add"
51 | ]
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Track.js:
--------------------------------------------------------------------------------
1 | import WaveSurfer from 'wavesurfer.js';
2 |
3 | export default class Track {
4 | constructor({ audioContext, stereoPannerNode, name }) {
5 | this.name = name;
6 | this.audioContext = audioContext;
7 | this.stereoPannerNode = stereoPannerNode;
8 | this.ready = false;
9 | this.gainNode = audioContext.createGain();
10 | this.gainValue = 1;
11 | this.active = true;
12 | this.playing = false;
13 | }
14 |
15 | async init(arrayBuffer) {
16 | const audioBuffer = await new Promise(res =>
17 | this.audioContext.decodeAudioData(arrayBuffer, res)
18 | );
19 | this.audioBuffer = audioBuffer;
20 | this.initAudioSource();
21 | this.ready = true;
22 | }
23 |
24 | initAudioSource() {
25 | this.audioSource = this.audioContext.createBufferSource();
26 | this.audioSource.buffer = this.audioBuffer;
27 | this.audioSource
28 | .connect(this.gainNode)
29 | .connect(this.stereoPannerNode)
30 | .connect(this.audioContext.destination);
31 | }
32 |
33 | play(when, offset = 0) {
34 | if (this.playing) {
35 | this.audioSource.stop(when);
36 | }
37 |
38 | this.initAudioSource();
39 | this.audioSource.start(when, offset);
40 | this.playing = true;
41 | }
42 |
43 | pause(when) {
44 | this.audioSource.stop(when);
45 | this.playing = false;
46 | }
47 |
48 | stop(when) {
49 | this.audioSource.stop(when);
50 | this.playing = false;
51 | }
52 |
53 | setWaveformPlayheadTime(playheadTime) {
54 | this.waveSurfer.seekTo(playheadTime / this.audioBuffer.duration);
55 | }
56 |
57 | initWaveform(options) {
58 | this.waveSurfer = WaveSurfer.create({ interact: false, ...options });
59 | this.waveSurfer.loadDecodedBuffer(this.audioBuffer);
60 | }
61 |
62 | isSoloOrActive(soloTrack) {
63 | if (soloTrack === this) {
64 | return true;
65 | } else if (soloTrack !== null) {
66 | return false;
67 | } else {
68 | return this.active;
69 | }
70 | }
71 |
72 | setGain(trackGainValue, soloTrack) {
73 | this.gainNode.gain.value = this.isSoloOrActive(soloTrack)
74 | ? trackGainValue + this.gainValue - 1
75 | : 0;
76 | }
77 |
78 | eventLoop(playPosition) {
79 | if (this.waveSurfer) {
80 | this.setWaveformPlayheadTime(playPosition);
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/click.js:
--------------------------------------------------------------------------------
1 | const audioContext = new AudioContext();
2 |
3 | const stereoPannerNode = new StereoPannerNode(audioContext, {
4 | pan: 0
5 | });
6 | const gainNode = audioContext.createGain();
7 |
8 | let audioBuffer = null;
9 | let upAudioBuffer = null;
10 |
11 | let eventLoopCount = 0;
12 |
13 | // Fetch click audio files
14 | export async function initClick() {
15 | const click = await fetch('./click.wav');
16 | const clickArrayBuffer = await click.arrayBuffer();
17 |
18 | const clickUp = await fetch('./click-accent.wav');
19 | const clickUpArrayBuffer = await clickUp.arrayBuffer();
20 |
21 | return new Promise(res => {
22 | audioContext.decodeAudioData(clickArrayBuffer, _ => {
23 | audioBuffer = _;
24 |
25 | audioContext.decodeAudioData(clickUpArrayBuffer, _ => {
26 | upAudioBuffer = _;
27 |
28 | res();
29 | });
30 | });
31 | });
32 | }
33 |
34 | export function clickEventLoop(store) {
35 | const { beats, unit } = store.state.clickTimeSignature;
36 | if (!beats || !unit) {
37 | return;
38 | }
39 |
40 | const clickInterval = getClickInterval(store.state);
41 | if (store.state.playPosition / eventLoopCount > clickInterval) {
42 | const bufferSource = audioContext.createBufferSource();
43 | bufferSource
44 | .connect(gainNode)
45 | .connect(stereoPannerNode)
46 | .connect(audioContext.destination);
47 |
48 | if (eventLoopCount % beats === 0) {
49 | bufferSource.buffer = upAudioBuffer;
50 | bufferSource.start();
51 | } else {
52 | bufferSource.buffer = audioBuffer;
53 | bufferSource.start();
54 | }
55 |
56 | eventLoopCount++;
57 | }
58 | }
59 |
60 | export function setClickPan(value) {
61 | stereoPannerNode.pan.value = value;
62 | }
63 |
64 | export function setClickEventLoopCount(count) {
65 | eventLoopCount = count;
66 | }
67 |
68 | export function setClickGain(value) {
69 | gainNode.gain.value = value;
70 | }
71 |
72 | export function getClickInterval(state) {
73 | return 60 / (state.clickBpm * (state.clickTimeSignature.unit / 4));
74 | }
75 |
76 | export function getClickBeats(state) {
77 | const beats = Math.floor(state.playPosition / getClickInterval(state));
78 | return [
79 | Math.floor(beats / state.clickTimeSignature.beats),
80 | (beats % state.clickTimeSignature.beats) + 1
81 | ];
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/Track.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{ number }}
14 | Solo
17 |
18 | mdi-delete
19 |
20 |
21 |
29 |
30 |
31 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
104 |
105 |
118 |
--------------------------------------------------------------------------------
/src/store/settings.js:
--------------------------------------------------------------------------------
1 | import { setClickPan, setClickGain } from '../click';
2 | import { initMidiEvents } from '../midi';
3 | import { setTrackGain, tracksStereoPannerNode } from '../tracks';
4 | import { set, get } from '../database';
5 |
6 | const store = {
7 | state: {
8 | trackPanning: 0,
9 | clickPanning: 0,
10 | midiDeviceName: null,
11 | clickGainValue: 1,
12 | trackGainValue: 1,
13 | /**
14 | * { [actionName]: { type: 'note' | 'controlChange' | 'key', value: any } }
15 | */
16 | controlEditMap: {}
17 | },
18 | getters: {
19 | getControlMapping(state) {
20 | return controlName => state.controlEditMap[controlName];
21 | }
22 | },
23 | mutations: {
24 | setTrackPanning(state, value) {
25 | state.trackPanning = value;
26 | },
27 | setClickPanning(state, value) {
28 | state.clickPanning = value;
29 | },
30 | setMidiDeviceName(state, value) {
31 | state.midiDeviceName = value;
32 | },
33 | setControlEdit(state, { key, value }) {
34 | state.controlEditMap[key] = value;
35 | },
36 | setMasterTrackGainValue(state, value) {
37 | state.trackGainValue = value;
38 | },
39 | setClickGainValue(state, value) {
40 | state.clickGainValue = value;
41 | }
42 | },
43 | actions: {
44 | setMasterTrackGainValue({ commit, rootState, state }, value) {
45 | commit('setMasterTrackGainValue', value);
46 | rootState.tracks.forEach(track => setTrackGain(track, state, rootState));
47 | saveSettings();
48 | },
49 | setTrackPanning({ commit }, value) {
50 | commit('setTrackPanning', value);
51 | tracksStereoPannerNode.pan.value = value;
52 | saveSettings();
53 | },
54 | setClickPanning({ commit }, value) {
55 | commit('setClickPanning', value);
56 | setClickPan(value);
57 | saveSettings();
58 | },
59 | setMidiDeviceName({ commit, rootState, dispatch }, value) {
60 | commit('setMidiDeviceName', value);
61 | initMidiEvents(value, { rootState, dispatch });
62 | saveSettings();
63 | },
64 | setControlEdit({ commit, rootState }, { type, value }) {
65 | commit('setControlEdit', {
66 | key: rootState.controlEditSelected,
67 | value: { type, value }
68 | });
69 | saveSettings();
70 | },
71 | setClickGainValue({ commit }, value) {
72 | commit('setClickGainValue', value);
73 | setClickGain(value);
74 | saveSettings();
75 | },
76 | triggerControlAction({ state, dispatch }, eventValue) {
77 | Object.entries(state.controlEditMap).find(([action, mapValue]) => {
78 | if (
79 | mapValue.type === eventValue.type &&
80 | mapValue.value === eventValue.value
81 | ) {
82 | dispatch(action);
83 | }
84 | });
85 | },
86 | async initSettings({ state, dispatch, rootState }) {
87 | const settings = await get('settings');
88 | Object.assign(state, settings);
89 | if (state.midiDeviceName) {
90 | initMidiEvents(state.midiDeviceName, { rootState, dispatch });
91 | }
92 | }
93 | }
94 | };
95 |
96 | function saveSettings() {
97 | set('settings', store.state);
98 | }
99 |
100 | export default store;
101 |
--------------------------------------------------------------------------------
/src/components/SettingsDialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Settings
5 |
6 |
7 |
12 |
17 |
18 |
26 |
27 |
34 |
35 |
40 |
41 | Edit MIDI and key mapping
42 |
43 | MIDI
50 | Key
56 |
57 |
58 |
59 |
60 |
61 | OK
62 |
63 |
64 |
65 |
66 |
67 |
133 |
--------------------------------------------------------------------------------
/src/components/Controls.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Done mapping
9 |
15 | {{ playPauseIcon }}
16 |
17 |
23 | {{ mdiStop }}
24 |
25 |
26 |
33 | {{ mdiMetronome }}
34 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
47 |
48 |
49 | {{ mdiWrench }}
50 |
51 |
52 | {{ mdiInformation }}
53 |
54 |
55 |
56 |
57 |
190 |
191 |
196 |
--------------------------------------------------------------------------------
/src/store/index.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import Vuex from 'vuex';
3 | import {
4 | clickEventLoop,
5 | setClickEventLoopCount,
6 | getClickBeats,
7 | getClickInterval
8 | } from '../click';
9 | import settings from './settings';
10 |
11 | import {
12 | tracksAudioContext,
13 | newTrack,
14 | setTrackGain,
15 | playTracks
16 | } from '../tracks';
17 |
18 | Vue.use(Vuex);
19 |
20 | let eventLoopStart = tracksAudioContext.currentTime;
21 | let trackEventLoopCount = 0;
22 |
23 | const store = new Vuex.Store({
24 | modules: {
25 | settings
26 | },
27 | state: {
28 | playState: 'stopped',
29 | playPosition: 0,
30 | tracks: [],
31 | clickActive: false,
32 | clickBpm: 102,
33 | soloTrack: null,
34 | dialog: null,
35 | loading: true,
36 | clickTimeSignature: {
37 | beats: 4,
38 | unit: 4
39 | },
40 | controlEditMode: null,
41 | controlEditSelected: null
42 | },
43 | getters: {
44 | getTrack(state) {
45 | return track => state.tracks.find(_ => _ === track);
46 | },
47 | playBeatsPosition(state) {
48 | return getClickBeats(state);
49 | }
50 | },
51 | mutations: {
52 | setPlayState(state, value) {
53 | state.playState = value;
54 | },
55 | /**
56 | * @param {Track} track
57 | */
58 | addTrack(state, track) {
59 | state.tracks.push(track);
60 | },
61 | removeTrack(state, track) {
62 | state.tracks.splice(state.tracks.indexOf(track), 1);
63 | },
64 | setClickActive(state, value) {
65 | state.clickActive = value;
66 | },
67 | setPlayPosition(state, value) {
68 | state.playPosition = value;
69 | },
70 |
71 | setTrackActive(state, { track, value }) {
72 | track.active = value;
73 | },
74 | setSoloTrack(state, track) {
75 | state.soloTrack = track;
76 | },
77 | setDialog(state, dialog) {
78 | state.dialog = dialog;
79 | },
80 | setClickBpm(state, value) {
81 | state.clickBpm = value;
82 | },
83 | setLoading(state, value) {
84 | state.loading = value;
85 | },
86 | setClickTimeSignature(state, value) {
87 | state.clickTimeSignature = value;
88 | },
89 | setControlEditMode(state, value) {
90 | state.controlEditMode = value;
91 | },
92 | setControlEditSelected(state, value) {
93 | state.controlEditSelected = value;
94 | }
95 | },
96 | actions: {
97 | playPause({ state, commit }) {
98 | const states = {
99 | stopped: 'playing',
100 | paused: 'playing',
101 | playing: 'paused'
102 | };
103 | const newState = states[state.playState];
104 | commit('setPlayState', newState);
105 | if (tracksAudioContext.state === 'suspended') {
106 | tracksAudioContext.resume();
107 | }
108 | if (newState === 'playing') {
109 | eventLoopStart = tracksAudioContext.currentTime;
110 | trackEventLoopCount = 0;
111 | playTracks(state.tracks, state.playPosition);
112 | } else {
113 | state.tracks.forEach(track => track.pause());
114 | }
115 | },
116 | playAt({ state, dispatch }, playPosition) {
117 | dispatch('setPlayPosition', playPosition);
118 | setClickEventLoopCount(
119 | Math.floor(playPosition / getClickInterval(state))
120 | );
121 | playTracks(state.tracks, state.playPosition);
122 | },
123 | stop({ commit, state }) {
124 | if (state.playState === 'playing') {
125 | state.tracks.forEach(track => track.stop());
126 | }
127 | commit('setPlayState', 'stopped');
128 | commit('setPlayPosition', 0);
129 | setClickEventLoopCount(0);
130 | state.tracks.forEach(track => track.eventLoop(store.state.playPosition));
131 | },
132 | addTrack({ commit }, { name, arrayBuffer }) {
133 | commit('addTrack', newTrack({ name, arrayBuffer }));
134 | },
135 | removeTrack({ commit }, track) {
136 | commit('removeTrack', track);
137 | },
138 | toggleClickActive({ commit, state }) {
139 | commit('setClickActive', !state.clickActive);
140 | },
141 | setTrackGainValue({ state }, { track, value }) {
142 | track.gainValue = value;
143 | setTrackGain(track, state.settings, state);
144 | },
145 | setTrackActive({ commit, state }, { track, value }) {
146 | commit('setTrackActive', { track, value });
147 | setTrackGain(track, state.settings, state);
148 | },
149 | setSoloTrack({ commit, state }, track) {
150 | commit('setSoloTrack', track);
151 | state.tracks.forEach(track => setTrackGain(track, state.settings, state));
152 | },
153 | toggleSettingsDialog({ commit, state }) {
154 | commit('setDialog', state.dialog === 'settings' ? null : 'settings');
155 | },
156 | toggleAboutDialog({ commit, state }) {
157 | commit('setDialog', state.dialog === 'about' ? null : 'about');
158 | },
159 | setClickBpm({ commit }, value) {
160 | commit('setClickBpm', value);
161 | },
162 | setClickTimeSignature({ commit }, value) {
163 | commit('setClickTimeSignature', value);
164 | },
165 |
166 | toggleControlEditMode({ commit, state }) {
167 | commit('setControlEditMode', !state.controlEditMode);
168 | commit('setDialog', null);
169 | },
170 | setControlEditSelected({ commit }, value) {
171 | commit('setControlEditSelected', value);
172 | },
173 | setPlayPosition({ commit }, value) {
174 | commit('setPlayPosition', value);
175 | }
176 | }
177 | });
178 |
179 | const trackEventLoopInterval = 0.01;
180 |
181 | setInterval(() => {
182 | if (store.state.playState === 'playing') {
183 | const trackDelta = tracksAudioContext.currentTime - eventLoopStart;
184 | if (trackDelta / trackEventLoopCount > trackEventLoopInterval) {
185 | trackEventLoop();
186 | trackEventLoopCount++;
187 | }
188 |
189 | clickEventLoop(store);
190 | }
191 | }, 1);
192 |
193 | function trackEventLoop() {
194 | store.commit(
195 | 'setPlayPosition',
196 | store.state.playPosition + trackEventLoopInterval
197 | );
198 | store.state.tracks.forEach(track =>
199 | track.eventLoop(store.state.playPosition)
200 | );
201 | }
202 |
203 | export default store;
204 |
--------------------------------------------------------------------------------