├── .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 | 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 | ![Screenshot](./screenshot.png) 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 | 11 | 12 | 35 | 36 | 44 | -------------------------------------------------------------------------------- /src/components/AboutDialog.vue: -------------------------------------------------------------------------------- 1 | 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 | 18 | 19 | 44 | 45 | 50 | -------------------------------------------------------------------------------- /src/components/TrackManager.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 54 | -------------------------------------------------------------------------------- /src/components/Clock.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 66 | 67 | 133 | -------------------------------------------------------------------------------- /src/components/Controls.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------