From faafe877d3e55346ac9e3141b6771127ec8c5573 Mon Sep 17 00:00:00 2001 From: wolfbiter Date: Sun, 23 Oct 2016 11:14:48 -0700 Subject: [PATCH 1/6] analyze around current mix position; when clicking on marker, snap to beat and clear markers --- .../mix-builder/precision-controls/track.js | 15 ++++--- app/components/mix-visual/track-clip.js | 39 +++++++++++++++-- app/lib/utils.js | 4 ++ app/services/beat-detection.js | 42 ++++++++++--------- 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/app/components/mix-builder/precision-controls/track.js b/app/components/mix-builder/precision-controls/track.js index 66658199..113ce1d4 100644 --- a/app/components/mix-builder/precision-controls/track.js +++ b/app/components/mix-builder/precision-controls/track.js @@ -109,6 +109,8 @@ const KeyboardBeatJumpMixin = Ember.Mixin.create(BEAT_JUMP_KEYBINDINGS.reduce( {} )); +const SECONDS_TO_ANALYZE = 20; + export default Ember.Component.extend( KeyboardBeatJumpMixin, EKMixin, @@ -134,8 +136,6 @@ export default Ember.Component.extend( const clip = this.get('clip'); const beatGrid = clip.get('audioMeta.beatGrid'); - console.log('beatJump', beats, direction, isNudge, clip.get('track.title')); - if (isNudge || this.get('isToTrackClip')) { const audioStartBeat = clip.get('audioStartBeat'); const newAudioStartBeat = audioStartBeat - (beats * direction); @@ -167,13 +167,18 @@ export default Ember.Component.extend( analyzeTrack() { const task = this.get('beatDetection.analyzeTrackTask'); const track = this.get('track'); - task.perform(track).then(({ peaks, intervals }) => { - const trackClip = this.get('clip'); + const trackClip = this.get('clip.content'); + const currentAudioTime = trackClip.getCurrentAudioTime(); + console.log({ currentAudioTime}) + + task.perform(track, { + startTime: currentAudioTime - SECONDS_TO_ANALYZE / 2, + endTime: currentAudioTime + SECONDS_TO_ANALYZE / 2, + }).then(({ peaks, intervals }) => { console.log('analyze track markers', peaks, intervals); trackClip.setProperties({ markers: peaks, - // audioStartTime: peaks[0].time, }); }); }, diff --git a/app/components/mix-visual/track-clip.js b/app/components/mix-visual/track-clip.js index ebf3bd89..7cd6e028 100644 --- a/app/components/mix-visual/track-clip.js +++ b/app/components/mix-visual/track-clip.js @@ -7,6 +7,9 @@ import MixVisualClipMixin from 'linx/mixins/components/mix-visual/clip'; import { constantTernary, propertyOrDefault } from 'linx/lib/computed/ternary'; import { FROM_TRACK_COLOR, TO_TRACK_COLOR } from 'linx/components/mix-builder'; +import { isValidNumber } from 'linx/lib/utils'; + +const MARKER_CLICK_WINDOW = 0.05; // [s] how close to a marker a click has to be export default ArrangementVisualTrackClip.extend( MixVisualClipMixin, { @@ -16,9 +19,12 @@ export default ArrangementVisualTrackClip.extend( actions: { onClick() { - // if (this.get('selectedTransition')) { + if (this.get('selectedTransition')) { + Ember.run.next(() => { + this._checkForMarkerClick(); + }); this.sendAction('selectClip', this.get('clip')); - // } + } }, onDrag(d3Context, d, dBeats) { @@ -34,6 +40,31 @@ export default ArrangementVisualTrackClip.extend( }, }, + // if marker was clicked, set as beatgrid and clear markers + _checkForMarkerClick() { + const trackClip = this.get('clip.content'); + const currentAudioTime = trackClip.getCurrentAudioTime(); + const markers = trackClip.get('markers') || []; + + const marker = markers.find(({ time }) => { + return (currentAudioTime - MARKER_CLICK_WINDOW <= time) && + (currentAudioTime + MARKER_CLICK_WINDOW >= time); + }); + + if (marker && isValidNumber(marker.time)) { + console.log('clicked marker', marker); + + const arrangement = trackClip.get('arrangement.content'); + const quantizedBeat = arrangement.get('beatGrid').quantizeBeat(arrangement.getCurrentBeat()); + const quantizedAudioTime = trackClip.getAudioTimeFromArrangementBeat(quantizedBeat); + + const prevAudioStartTime = trackClip.get('audioStartTime'); + const timeDelta = quantizedAudioTime - marker.time; + trackClip.set('audioStartTime', prevAudioStartTime - timeDelta); + trackClip.set('markers', []); + } + }, + // used to keep track of where audio was when drag started _dragStartBeat: 0, @@ -51,8 +82,8 @@ export default ArrangementVisualTrackClip.extend( row: constantTernary('isSelectedToTrackClip', 2, 0), waveColor: Ember.computed('isSelectedFromTrackClip', 'isSelectedToTrackClip', function() { - if (this.get('isSelectedFromTrackClip')) return FROM_TRACK_COLOR; - if (this.get('isSelectedToTrackClip')) return TO_TRACK_COLOR; + if (this.get('isSelectedFromTrackClip')) { return FROM_TRACK_COLOR; } + if (this.get('isSelectedToTrackClip')) { return TO_TRACK_COLOR; } return 'steelblue'; }), diff --git a/app/lib/utils.js b/app/lib/utils.js index 01eca15d..90597838 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -37,6 +37,10 @@ export const isValidNumber = function(number) { return isNumber(number) && isFinite(number); }; +export const validNumberOrDefault = function(number, _default) { + return isValidNumber(number) ? number : _default; +}; + export const isObject = function(object) { return Ember.typeOf(object) === 'object'; }; diff --git a/app/services/beat-detection.js b/app/services/beat-detection.js index 877f9061..7e6bf6b9 100644 --- a/app/services/beat-detection.js +++ b/app/services/beat-detection.js @@ -2,25 +2,31 @@ import Ember from 'ember'; import { task } from 'ember-concurrency'; +import { validNumberOrDefault } from 'linx/lib/utils'; + const SAMPLE_RATE = 44100; -const SECONDS_TO_ANALYZE = 60; export default Ember.Service.extend({ - analyzeTrackTask: task(function * (track) { - console.log('analyze track', track && track.get('title')); + analyzeTrackTask: task(function * (track, options) { + console.log('analyze track', track && track.get('title'), options); Ember.assert('Must provide track to BeatDetection#analyzeTrack', !!track); const audioBuffer = yield track.get('audioBinary.decodedArrayBuffer'); if (!audioBuffer) { return; } - const peaks = yield _getFilteredPeaks(audioBuffer); + const startTime = Math.max(validNumberOrDefault(options.startTime, 0), 0); + const endTime = validNumberOrDefault(options.endTime, 60); + const startPosition = startTime * SAMPLE_RATE; + + const peaks = yield _getFilteredPeaks(audioBuffer, startTime, endTime); const intervals = _getIntervals(peaks); const topIntervals = intervals.splice(0, 5); // return peaks with time [s], volume return { peaks: peaks.map(({ position, volume }) => { + position += startPosition; return { position, time: (position / SAMPLE_RATE) - (45 / 1000), // adjust to be slightly before beat @@ -35,9 +41,11 @@ export default Ember.Service.extend({ // computed a filtered version of audioBuffer // adapted from https://github.com/JMPerez/beats-audio-api -function _getFilteredPeaks(audioBuffer) { +function _getFilteredPeaks(audioBuffer, startTime, endTime) { + const secondsToAnalyze = endTime - startTime; + const OfflineContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; - const offlineContext = new OfflineContext(2, SECONDS_TO_ANALYZE * SAMPLE_RATE, SAMPLE_RATE); + const offlineContext = new OfflineContext(2, secondsToAnalyze * SAMPLE_RATE, SAMPLE_RATE); const source = offlineContext.createBufferSource(); source.buffer = audioBuffer; @@ -46,34 +54,28 @@ function _getFilteredPeaks(audioBuffer) { // Below this is often the bassline. So let's focus just on that. // First a lowpass to remove most of the song. - const lowpass = offlineContext.createBiquadFilter(); lowpass.type = "lowpass"; lowpass.frequency.value = 150; lowpass.Q.value = 1; // Run the output of the source through the low pass. - source.connect(lowpass); // Now a highpass to remove the bassline. - const highpass = offlineContext.createBiquadFilter(); highpass.type = "highpass"; highpass.frequency.value = 100; highpass.Q.value = 1; // Run the output of the lowpass through the highpass. - lowpass.connect(highpass); // Run the output of the highpass through our offline context. - highpass.connect(offlineContext.destination); // Start the source, and render the output into the offline conext. - - source.start(0); + source.start(0, startTime); return offlineContext.startRendering().then((filteredBuffer) => { return _getPeaks([filteredBuffer.getChannelData(0), filteredBuffer.getChannelData(1)]); }); @@ -100,14 +102,14 @@ function _getPeaks(data) { // This will allow us to ignore breaks, and allow us to address tracks with // a BPM below 120. - var partSize = SAMPLE_RATE / 2, - parts = data[0].length / partSize, - peaks = []; + const partSize = SAMPLE_RATE / 2; + const parts = data[0].length / partSize; + let peaks = []; - for (var i = 0; i < parts; i++) { - var max = 0; - for (var j = i * partSize; j < (i + 1) * partSize; j++) { - var volume = Math.max(Math.abs(data[0][j]), Math.abs(data[1][j])); + for (let i = 0; i < parts; i++) { + let max = 0; + for (let j = i * partSize; j < (i + 1) * partSize; j++) { + const volume = Math.max(Math.abs(data[0][j]), Math.abs(data[1][j])); if (!max || (volume > max.volume)) { max = { position: j, From bb7a3745cf454c3b13eaf65a127c44c477a103df Mon Sep 17 00:00:00 2001 From: wolfbiter Date: Sun, 23 Oct 2016 11:21:05 -0700 Subject: [PATCH 2/6] close transition on escape --- app/components/mix-builder.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/components/mix-builder.js b/app/components/mix-builder.js index d05b37aa..9b42da4d 100644 --- a/app/components/mix-builder.js +++ b/app/components/mix-builder.js @@ -60,6 +60,10 @@ export default Ember.Component.extend( this.send('playpause'); })), + _exitTransitionOnEscape: Ember.on(keyDown('Escape'), makeKeybinding(function(e) { + this.send('selectTransition', null); + })), + _pauseMix: Ember.on('willDestroyElement', function() { this.send('pause'); }), From 49c36d9fe7af1071a028c11aa1be507e79479b7a Mon Sep 17 00:00:00 2001 From: wolfbiter Date: Sun, 23 Oct 2016 11:52:25 -0700 Subject: [PATCH 3/6] fix minor bug with beat detection --- app/services/beat-detection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/beat-detection.js b/app/services/beat-detection.js index 7e6bf6b9..2f0c30b1 100644 --- a/app/services/beat-detection.js +++ b/app/services/beat-detection.js @@ -7,7 +7,7 @@ import { validNumberOrDefault } from 'linx/lib/utils'; const SAMPLE_RATE = 44100; export default Ember.Service.extend({ - analyzeTrackTask: task(function * (track, options) { + analyzeTrackTask: task(function * (track, options = {}) { console.log('analyze track', track && track.get('title'), options); Ember.assert('Must provide track to BeatDetection#analyzeTrack', !!track); From 622f1dac287cd24ce905d0e7ce0cce544f257f01 Mon Sep 17 00:00:00 2001 From: wolfbiter Date: Sat, 8 Oct 2016 14:36:53 -0700 Subject: [PATCH 4/6] allow soundtouch dspBufLength to be different from bufferLength --- app/lib/soundtouch.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/lib/soundtouch.js b/app/lib/soundtouch.js index 5be1cca2..476950e8 100644 --- a/app/lib/soundtouch.js +++ b/app/lib/soundtouch.js @@ -125,7 +125,7 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi // if playing, calculate expected vs actual position if (extractFrameCount !== 0) { const actualElapsedSamples = Math.max(0, filter.position - filterStartPosition + extractFrameCount); - const elapsedTime = Math.min(audioContext.currentTime, endTime) - startTime; + const elapsedTime = Math.min(playbackTime, endTime) - startTime; const expectedElapsedSamples = Math.max(0, elapsedTime * sampleRate); const sampleDelta = ~~(expectedElapsedSamples - actualElapsedSamples); @@ -137,7 +137,7 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi // if we're behind where we should be, extract dummy frames to catch up if (sampleDelta > 0) { - // console.log("DRIFT", sampleDelta, extractFrameCount, windowBufferSize); + // console.log("DRIFT", playbackTime, sampleDelta, extractFrameCount, windowBufferSize); const dummySamples = new Float32Array(sampleDelta * channelCount); const dummyFramesExtracted = filter.extract(dummySamples, sampleDelta); @@ -170,7 +170,6 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi numberOfInputs: 2, numberOfOutputs: 2, bufferLength: windowBufferSize, - dspBufLength: windowBufferSize, parameters: [ { name: 'pitch', From ace3d962816272f7de1c38176c94d53a09f5adf4 Mon Sep 17 00:00:00 2001 From: wolfbiter Date: Sat, 8 Oct 2016 15:56:30 -0700 Subject: [PATCH 5/6] WIP: mix variable tempo WIP: fix scale issues minor successS: got workaround for non-computing property in playable arrangement --- app/components/mix-builder.js | 1 + app/components/mix-visual/track-clip.js | 1 + app/initializers/catch-errors.js | 16 +- app/lib/soundtouch.js | 22 +- app/lib/web-audio/gain-node.js | 2 +- app/lib/web-audio/soundtouch-node.js | 5 +- app/lib/web-audio/track-source-node.js | 140 ----------- app/mixins/playable-arrangement.js | 83 +++--- .../automatable-clip/control.js | 32 +-- app/mixins/playable-arrangement/beat-grid.js | 237 ++++++++++++++++++ app/mixins/playable-arrangement/clip.js | 12 +- app/mixins/playable-arrangement/metronome.js | 34 +-- app/mixins/playable-arrangement/track-clip.js | 56 +++-- app/models/arrangement/automation-clip.js | 2 +- app/models/mix.js | 46 +++- app/models/mix/transition.js | 68 ++++- app/models/mix/transition/automation-clip.js | 14 +- .../automation-clip/control-point.js | 2 + .../transition/from-track-automation-clip.js | 1 - .../mix/transition/from-track-tempo-clip.js | 10 + .../transition/to-track-automation-clip.js | 1 - .../mix/transition/to-track-tempo-clip.js | 10 + app/models/mix/transition/track-tempo-clip.js | 24 ++ app/models/track.js | 2 +- app/models/track/audio-meta.js | 4 +- app/models/track/audio-meta/beat-grid.js | 4 +- .../unit/models/audio-meta/beat-grid-test.js | 1 - tests/unit/models/track/audio-meta-test.js | 1 - 28 files changed, 531 insertions(+), 300 deletions(-) create mode 100644 app/mixins/playable-arrangement/beat-grid.js create mode 100644 app/models/mix/transition/from-track-tempo-clip.js create mode 100644 app/models/mix/transition/to-track-tempo-clip.js create mode 100644 app/models/mix/transition/track-tempo-clip.js diff --git a/app/components/mix-builder.js b/app/components/mix-builder.js index 9b42da4d..55a1d990 100644 --- a/app/components/mix-builder.js +++ b/app/components/mix-builder.js @@ -304,6 +304,7 @@ export default Ember.Component.extend( seekToBeat(beat) { const quantizedBeat = this._quantizeBeat(beat); + console.log('seekToBeat', beat, quantizedBeat); this.get('mix').seekToBeat(quantizedBeat); this.trigger('seekToBeat', quantizedBeat); diff --git a/app/components/mix-visual/track-clip.js b/app/components/mix-visual/track-clip.js index 7cd6e028..f3e9194e 100644 --- a/app/components/mix-visual/track-clip.js +++ b/app/components/mix-visual/track-clip.js @@ -51,6 +51,7 @@ export default ArrangementVisualTrackClip.extend( (currentAudioTime + MARKER_CLICK_WINDOW >= time); }); + // TODO(TRACKMULTIGRID): refactor to use dynamic beatgrids instead of static time if (marker && isValidNumber(marker.time)) { console.log('clicked marker', marker); diff --git a/app/initializers/catch-errors.js b/app/initializers/catch-errors.js index 3d4bec34..3d0e9511 100644 --- a/app/initializers/catch-errors.js +++ b/app/initializers/catch-errors.js @@ -1,13 +1,13 @@ import Ember from 'ember'; -// all uncaught errors will be caught here -// you can use `message` to make sure it's the error you're looking for -// returning true overrides the default window behaviour -window.onerror = function(message, file, lineNumber, columnNumber, error) { - console.warn(message, error && error.stack); - window.error = error; - return true; -}; +// // all uncaught errors will be caught here +// // you can use `message` to make sure it's the error you're looking for +// // returning true overrides the default window behaviour +// window.onerror = function(message, file, lineNumber, columnNumber, error) { +// console.warn(message, error && error.stack); +// window.error = error; +// return true; +// }; export default { name: 'CatchErrors', diff --git a/app/lib/soundtouch.js b/app/lib/soundtouch.js index 476950e8..26e9559f 100644 --- a/app/lib/soundtouch.js +++ b/app/lib/soundtouch.js @@ -40,7 +40,8 @@ FifoSampleBuffer.prototype.clear = function() { // // TODO(TECHDEBT): window.BUFFER_SIZE set by mix builder window.MAX_BUFFER_SIZE = 16384; -window.BUFFER_SIZE = MAX_BUFFER_SIZE / 8; +window.BUFFER_SIZE = window.MAX_BUFFER_SIZE / 8; +const DSP_BUFFER_LENGTH = 128; const SAMPLE_DRIFT_TOLERANCE = 512; export function SoundtouchBufferSource(buffer) { @@ -69,13 +70,12 @@ SoundtouchBufferSource.prototype = { } }; -export function createSoundtouchNode({ audioContext, filter, startTime, offsetTime, endTime, defaultTempo, defaultPitch }) { - console.log('createSoundtouchNode') +export function createSoundtouchNode({ audioContext, filter, startTime, offsetTime, endTime, defaultPitch }) { const channelCount = 2; const windowBufferSize = window.BUFFER_SIZE; - if (!(audioContext && filter - && isValidNumber(startTime) && isValidNumber(offsetTime) && isValidNumber(endTime))) { + if (!(audioContext && filter && + isValidNumber(startTime) && isValidNumber(offsetTime) && isValidNumber(endTime))) { Ember.Logger.warn('Must provide all params to createSoundtouchNode', endTime); return; } @@ -87,6 +87,7 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi filter.sourcePosition = startSample; const filterStartPosition = filter.position; + const soundtouch = filter.pipe; function onaudioprocess({ type, @@ -101,10 +102,9 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi const l = outputs[0][0]; const r = outputs[0][1]; - // naively take first pitch and tempo values for this sample + // TODO(MULTIGRID): average tempo, pitch across buffer const pitch = parameters.pitch && parameters.pitch[0]; const tempo = parameters.tempo && parameters.tempo[0]; - const soundtouch = filter.pipe; if (isValidNumber(pitch)) { soundtouch.pitchSemitones = pitch; @@ -158,18 +158,16 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi r[i] = (samples[filterFrame * 2 + 1] * isPlaying[i]) || 0; filterFrame += isPlaying[i]; } - }; + } defaultPitch = parseFloat(defaultPitch); defaultPitch = isValidNumber(defaultPitch) ? defaultPitch : 0; - defaultTempo = parseFloat(defaultTempo); - defaultTempo = isValidNumber(defaultTempo) ? defaultTempo : 1; - const node = new AudioWorkerNode(audioContext, onaudioprocess, { numberOfInputs: 2, numberOfOutputs: 2, bufferLength: windowBufferSize, + dspBufLength: DSP_BUFFER_LENGTH, parameters: [ { name: 'pitch', @@ -177,7 +175,7 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi }, { name: 'tempo', - defaultValue: defaultTempo, + defaultValue: 1, }, { name: 'isPlaying', diff --git a/app/lib/web-audio/gain-node.js b/app/lib/web-audio/gain-node.js index 96b83315..9a68fb89 100644 --- a/app/lib/web-audio/gain-node.js +++ b/app/lib/web-audio/gain-node.js @@ -13,7 +13,7 @@ export default Ember.ObjectProxy.extend( const node = this.get('node'); const value = this.get('value'); - console.log('update gain value', value, isValidNumber(value)) + // console.log('update gain value', value, isValidNumber(value)) if (node && isValidNumber(value)) { node.gain.value = value; } diff --git a/app/lib/web-audio/soundtouch-node.js b/app/lib/web-audio/soundtouch-node.js index be3faa82..9ac6d3e7 100644 --- a/app/lib/web-audio/soundtouch-node.js +++ b/app/lib/web-audio/soundtouch-node.js @@ -20,8 +20,8 @@ export default Ember.ObjectProxy.extend( node: null, // set by `start` method, unset by `disconnect` outputNode: null, - // TODO(V2): TODO(MULTIGRID): tempo, transpose dynamic - start(startTime, offsetTime, endTime, tempo, transpose) { + // TODO(V2): transpose dynamic + start(startTime, offsetTime, endTime, transpose) { // Ember.Logger.log('currentTime', this.get('audioContext.currentTime')); // Ember.Logger.log('startSource', startTime, offsetTime); this.stop(); @@ -36,7 +36,6 @@ export default Ember.ObjectProxy.extend( startTime, offsetTime, endTime, - defaultTempo: tempo, defaultPitch: transpose, }); this.set('node', node); diff --git a/app/lib/web-audio/track-source-node.js b/app/lib/web-audio/track-source-node.js index 09aed47f..39a403c5 100644 --- a/app/lib/web-audio/track-source-node.js +++ b/app/lib/web-audio/track-source-node.js @@ -3,7 +3,6 @@ import Ember from 'ember'; import BufferSourceNode from './buffer-source-node'; import RequireAttributes from 'linx/lib/require-attributes'; -// TODO(REFACTOR): create base track FX chain + audio source node + soundtouch node export default BufferSourceNode.extend({ // params @@ -17,142 +16,3 @@ export default BufferSourceNode.extend({ return ''; }, }); - - -/* global SimpleFilter:true */ - -// import SoundTouch from 'linx/lib/soundtouch'; -// import { WebAudioBufferSource, getWebAudioNode } from 'linx/lib/soundtouch'; - - // TODO(REFACTOR) move into fx chain - // updateTempo: function() { - // var wavesurfer = this.get('wavesurfer'); - // var tempo = this.get('tempo'); - // if (wavesurfer) { - // wavesurfer.setTempo(tempo); - // } - // }.observes('wavesurfer', 'tempo'), - - // updatePitch: function() { - // var wavesurfer = this.get('wavesurfer'); - // var pitch = this.get('pitch'); - // if (wavesurfer) { - // wavesurfer.setPitch(pitch); - // } - // }.observes('wavesurfer', 'pitch'), - - // updateVolume: function() { - // var wavesurfer = this.get('wavesurfer'); - // var volume = this.get('volume'); - // if (wavesurfer) { - - // // TODO(EASY): remove this check, only for two-way binding to input - // try { - // volume = parseFloat(volume); - // } catch(e) {} - - // if (typeof volume !== 'number' || !volume) { - // volume = 0; - // } - - // wavesurfer.setVolume(volume); - // } - // }.observes('wavesurfer', 'volume'), - - -// TODO(REFACTOR): figure this out -// -// Wavesurfer + SoundTouch Integration -// - -// Wavesurfer.setTempo = function(tempo) { -// this.backend.setTempo(tempo); -// }; - -// Wavesurfer.setPitch = function(pitch) { -// this.backend.setPitch(pitch); -// }; - -// Wavesurfer.WebAudio.setTempo = function(tempo) { -// // Ember.Logger.log("setting tempo", tempo); -// if (typeof tempo !== 'number' || !tempo) { -// tempo = 1; -// } - -// // update startPosition and lastPlay for new tempo -// this.startPosition += this.getPlayedTime(); -// this.lastPlay = this.ac.currentTime; - -// this.linxTempo = this.playbackRate = tempo; - -// // update soundtouch tempo -// var soundtouch = this.soundtouch; -// if (soundtouch) { -// soundtouch.tempo = tempo; -// } -// }; - -// Wavesurfer.WebAudio.setPitch = function(pitch) { -// // Ember.Logger.log("setting pitch", pitch); - -// // TODO: remove this check, only for two-way binding to input -// try { -// pitch = parseFloat(pitch); -// } catch(e) { - -// } -// if (typeof pitch !== 'number') { -// pitch = 0; -// } - -// this.linxPitch = pitch; - -// // update soundtouch pitch -// var soundtouch = this.soundtouch; -// if (soundtouch) { -// soundtouch.pitchSemitones = pitch; -// } -// }; - -// // 'play' is equivalent to 'create and connect soundtouch source' -// Wavesurfer.WebAudio.play = function(start, end) { -// if (!this.isPaused()) { -// this.pause(); -// } - -// var adjustedTime = this.seekTo(start, end); -// start = adjustedTime.start; -// end = adjustedTime.end; -// this.scheduledPause = end; -// var startSample = ~~(start * this.ac.sampleRate); - -// // init soundtouch -// this.soundtouch = new SoundTouch(); -// this.setPitch(this.linxPitch); -// this.setTempo(this.linxTempo); - -// // hook up soundtouch node -// this.soundtouchSource = new WebAudioBufferSource(this.buffer); -// this.soundtouchFilter = new SimpleFilter(this.soundtouchSource, this.soundtouch); -// this.soundtouchFilter.sourcePosition = startSample; -// this.soundtouchNode = getWebAudioNode(this.ac, this.soundtouchFilter); -// this.soundtouchNode.connect(this.analyser); - -// this.setState(this.PLAYING_STATE); -// this.fireEvent('play'); -// }; - -// // 'pause' is equivalent to 'disconnect soundtouch source' -// Wavesurfer.WebAudio.pause = function() { -// this.scheduledPause = null; -// this.startPosition += this.getPlayedTime(); - -// this.soundtouchNode && this.soundtouchNode.disconnect(); - -// this.setState(this.PAUSED_STATE); -// }; - -// // turn into no-op -// Wavesurfer.WebAudio.createSource = function() {}; - -// export default Wavesurfer; diff --git a/app/mixins/playable-arrangement.js b/app/mixins/playable-arrangement.js index 4a44aeea..cf3dce64 100644 --- a/app/mixins/playable-arrangement.js +++ b/app/mixins/playable-arrangement.js @@ -1,21 +1,28 @@ import Ember from 'ember'; -import ReadinessMixin from 'linx/mixins/readiness'; -import RequireAttributes from 'linx/lib/require-attributes'; +import d3 from 'd3'; + import withDefault from 'linx/lib/computed/with-default'; import Metronome from './playable-arrangement/metronome'; import WebAudioMergerNode from 'linx/lib/web-audio/merger-node'; import computedObject from 'linx/lib/computed/object'; -import BeatGrid from 'linx/models/track/audio-meta/beat-grid'; +import BeatGrid from './playable-arrangement/beat-grid'; import { flatten, isValidNumber } from 'linx/lib/utils'; import GainNode from 'linx/lib/web-audio/gain-node'; // Interface for playable arrangements of clips -export default Ember.Mixin.create( - RequireAttributes('clips', 'audioContext'), - ReadinessMixin('isPlayableArrangementReady'), { +export default Ember.Mixin.create({ + // ReadinessMixin('isPlayableArrangementReady'), { + + // required params + clips: null, + bpmControlPoints: null, + audioContext: null, + + // optional params + outputNode: Ember.computed.reads('audioContext.destination'), + timeSignature: 4, - // params playpause(beat) { this.get('metronome').playpause(beat); }, @@ -44,21 +51,32 @@ export default Ember.Mixin.create( this.get('metronome').seekToBeat(beat); }, - // optional params - outputNode: Ember.computed.reads('audioContext.destination'), - bpm: 128.0, - isPlaying: Ember.computed.reads('metronome.isPlaying'), + duration: Ember.computed.reads('beatGrid.duration'), metronome: computedObject(Metronome, { 'audioContext': 'audioContext', - 'arrangement': 'this' + 'beatGrid': 'beatGrid', + }), + + bpmScale: Ember.computed('bpmControlPoints.@each.{beat,value}', 'beatCount', function() { + const beatCount = this.get('beatCount'); + const bpmControlPoints = this.get('bpmControlPoints') || []; + + const bpmControlBeats = bpmControlPoints.mapBy('beat'); + const bpmControlValues = bpmControlPoints.mapBy('value'); + + const firstBpmValue = bpmControlValues.get('firstObject'); + const lastBpmValue = bpmControlValues.get('lastObject'); + + return d3.scale.linear() + .domain([0].concat(bpmControlBeats).concat([beatCount])) + .range([firstBpmValue].concat(bpmControlValues).concat([lastBpmValue])) + .clamp(true); }), - beatGrid: computedObject(BeatGrid, { - duration: 'duration', - bpm: 'bpm', - timeSignature: 'timeSignature', + beatGrid: Ember.computed('bpmScale', 'beatCount', 'timeSignature', function() { + return BeatGrid.create(this.getProperties('bpmScale', 'beatCount', 'timeSignature')); }), // TODO(REFACTOR): arrangement shouldn't have to wait on clips? clips will just update when loaded @@ -69,13 +87,26 @@ export default Ember.Mixin.create( validClips: Ember.computed.filterBy('clips', 'isValid', true), readyClips: Ember.computed.filterBy('clips', 'isReady', true), - // TODO(CLEANUP): why? + + // TODO(TECHDEBT): have to async update sortedClips in next runloop, + // weird bug with computed properties not updating correctly otherwise // clipSort: ['endBeat:asc', 'startBeat:asc'], // sortedClips: Ember.computed.sort('clips', 'clipSort'), - sortedClips: Ember.computed('clips.@each.endBeat', function() { - return this.get('clips').sortBy('endBeat'); - }), - timeSignature: 4, + // sortedClips: Ember.computed('clips.@each.endBeat', function() { + // return this.get('clips').sortBy('endBeat'); + // }), + _updateSortedClips: Ember.observer('clips.@each.endBeat', function() { + Ember.RSVP.all(this.get('clips')).then((clips) => { + Ember.run.next(() => { + this.set('sortedClips', this.get('clips') + .reject((clip) => !clip) + .sortBy('endBeat') + ); + }); + }); + }).on('init'), + sortedClips: Ember.computed(() => []), + endBeat: Ember.computed.reads('sortedClips.lastObject.endBeat'), beatCount: withDefault('endBeat', 0), @@ -83,17 +114,11 @@ export default Ember.Mixin.create( return this.get('beatCount') / this.get('timeSignature'); }), - // duration of arrangement in [s] - // TODO(MULTIGRID) - duration: Ember.computed('metronome.bpm', 'beatCount', function() { - return this.get('metronome').getDuration(0, this.get('beatCount')); - }), - getRemainingDuration() { - const metronome = this.get('metronome'); + const beatGrid = this.get('beatGrid'); const beatCount = this.get('beatCount'); const currentBeat = this.getCurrentBeat(); - return metronome.getDuration(currentBeat, beatCount - currentBeat); + return beatGrid.getDuration(currentBeat, beatCount - currentBeat); }, getCurrentBeat() { diff --git a/app/mixins/playable-arrangement/automatable-clip/control.js b/app/mixins/playable-arrangement/automatable-clip/control.js index 936c30f2..f4ba73b4 100644 --- a/app/mixins/playable-arrangement/automatable-clip/control.js +++ b/app/mixins/playable-arrangement/automatable-clip/control.js @@ -7,13 +7,17 @@ import RequireAttributes from 'linx/lib/require-attributes'; import { isValidNumber } from 'linx/lib/utils'; export const CONTROL_TYPE_VOLUME = 'gain'; +export const CONTROL_TYPE_TEMPO = 'tempo'; +export const CONTROL_TYPE_BPM = 'bpm'; // currently only exists in transition +export const CONTROL_TYPE_PITCH = 'pitch'; + export const CONTROL_TYPE_LOW_BAND = 'low-band'; export const CONTROL_TYPE_MID_BAND = 'mid-band'; export const CONTROL_TYPE_HIGH_BAND = 'high-band'; -export const CONTROL_TYPE_BPM = 'bpm'; -export const CONTROL_TYPE_PITCH = 'pitch'; + export const CONTROL_TYPE_DELAY_WET = 'delay-wet'; export const CONTROL_TYPE_DELAY_CUTOFF = 'delay-cutoff'; + export const CONTROL_TYPE_FILTER_HIGHPASS_CUTOFF = 'filter-highpass-cutoff'; export const CONTROL_TYPE_FILTER_HIGHPASS_Q = 'filter-highpass-q'; export const CONTROL_TYPE_FILTER_LOWPASS_CUTOFF = 'filter-lowpass-cutoff'; @@ -22,6 +26,7 @@ export const CONTROL_TYPE_FILTER_LOWPASS_Q = 'filter-lowpass-q'; export const CONTROL_TYPES = [ CONTROL_TYPE_VOLUME, CONTROL_TYPE_BPM, + CONTROL_TYPE_TEMPO, CONTROL_TYPE_PITCH, CONTROL_TYPE_DELAY_WET, CONTROL_TYPE_DELAY_CUTOFF, @@ -39,7 +44,7 @@ export const CONTROL_TYPES = [ // audioParamPath is a path to a Web Audio AudioParam export default function(audioParamPath) { return Ember.Mixin.create( - RequireAttributes('clip'), { + new RequireAttributes('clip'), { audioParamPath, @@ -49,28 +54,9 @@ export default function(audioParamPath) { // optional params description: '', - // isSuspended: false, + valueScale: Ember.computed(() => d3.scale.identity()), defaultValue: 0, - // TODO(TECHDEBT): share more cleanly - valueScale: Ember.computed('type', function() { - switch (this.get('type')) { - case CONTROL_TYPE_DELAY_CUTOFF: - case CONTROL_TYPE_FILTER_HIGHPASS_CUTOFF: - case CONTROL_TYPE_FILTER_LOWPASS_CUTOFF: - return d3.scale.log().domain([20, 22050]).range([0, 1]); - case CONTROL_TYPE_LOW_BAND: - case CONTROL_TYPE_MID_BAND: - case CONTROL_TYPE_HIGH_BAND: - return d3.scale.linear().domain([-40, 40]).range([0, 1]); - case CONTROL_TYPE_FILTER_HIGHPASS_Q: - case CONTROL_TYPE_FILTER_LOWPASS_Q: - return d3.scale.linear().domain([0.001, 10]).range([0, 1]); - default: - return d3.scale.identity(); - } - }), - _initClipListeners: Ember.on('init', function() { const clip = this.get('clip'); clip && clip.on('schedule', this, this.scheduleDidChange); diff --git a/app/mixins/playable-arrangement/beat-grid.js b/app/mixins/playable-arrangement/beat-grid.js new file mode 100644 index 00000000..3dcaf146 --- /dev/null +++ b/app/mixins/playable-arrangement/beat-grid.js @@ -0,0 +1,237 @@ +import Ember from 'ember'; +import d3 from 'd3'; + +import LinearScale from 'linx/lib/linear-scale'; +import QuantizeScale from 'linx/lib/quantize-scale'; +import computedObject from 'linx/lib/computed/object'; +import { timeToBeatUtil, bpmToSpb, isValidNumber } from 'linx/lib/utils'; + +export const BAR_QUANTIZATION = 'bar'; +export const BEAT_QUANTIZATION = 'beat'; +export const TICK_QUANTIZATION = 'tick'; +export const MS10_QUANTIZATION = '10ms'; +export const MS1_QUANTIZATION = '1ms'; +export const SAMPLE_QUANTIZATION = 'sample'; + +export const TICKS_PER_BEAT = 120; + + +export default Ember.Object.extend({ + + // required params + bpmScale: null, + beatCount: null, + + // optional params + timeSignature: 4, + + timeToBeat(time) { + return this.get('beatScale')(time); + }, + + beatToTime(beat) { + return this.get('beatScale').invert(beat); + }, + + beatToBar(beat) { + return beat / this.get('timeSignature'); + }, + + barToBeat(bar) { + return bar * this.get('timeSignature'); + }, + + timeToBar(time) { + return this.get('barScale')(time); + }, + + barToTime(bar) { + return this.get('barScale').invert(bar); + }, + + quantizeBeat(beat) { + return this.get('quantizeBeatScale')(beat); + }, + + quantizeBar(bar) { + return this.get('quantizeBarScale')(bar); + }, + + beatToQuantizedBar(beat) { + return this.quantizeBar(this.beatToBar(beat)); + }, + + beatToQuantizedDownbeat(beat) { + // console.log('beatToQuantizedDownbeat', { + // beat, + // bar: this.beatToBar(beat), + // quantizedBar: this.beatToQuantizedBar(beat), + // quantizedBeat: this.barToBeat(this.beatToQuantizedBar(beat)), + // }); + return this.barToBeat(this.beatToQuantizedBar(beat)); + }, + + timeToQuantizedBeat(time) { + return this.quantizeBeat(this.timeToBeat(time)); + }, + + timeToQuantizedBar(time) { + return this.quantizeBar(this.timeToBar(time)); + }, + + duration: Ember.computed('beatScale', 'beatCount', function() { + return this.beatToTime(this.get('beatCount')); + }), + + // returns time duration of given beat interval + getDuration(startBeat, endBeat) { + const startTime = this.beatToTime(startBeat); + const endTime = this.beatToTime(endBeat); + return endTime - startTime; + }, + + // returns beat count of given time interval + getBeatCount(startTime, endTime) { + const startBeat = this.timeToBeat(startTime); + const endBeat = this.timeToBeat(endTime); + return endBeat - startBeat; + }, + + // Beat Scale + // domain is time [s] + // range is beats [b] + beatScaleDomain: Ember.computed('bpmScale', function() { + const { bpmScale } = this.getProperties('beatCount', 'bpmScale'); + + // add durations from each linear interval + const domain = bpmScale.domain(); + return domain.reduce((range, endBeat, i) => { + if (i === 0) { + range.push(0); + return range; + } + + const prevDuration = range[i - 1]; + + const startBeat = domain[i - 1]; + const startBpm = bpmScale(startBeat); + const endBpm = bpmScale(endBeat); + + const intervalBeatCount = endBeat - startBeat; + const averageBpm = (endBpm + startBpm) / 2.0; + const minutes = intervalBeatCount / averageBpm; + const seconds = minutes * 60; + + console.log('calculate seconds', { + startBeat, + endBeat, + startBpm, + endBpm, + intervalBeatCount, + averageBpm, + minutes, + seconds, + prevDuration + }); + + if (isValidNumber(seconds)) { + range.push(prevDuration + seconds); + } else { + range.push(prevDuration); + } + + return range; + }, []); + }), + beatScaleRange: Ember.computed('bpmScale', function() { + return this.get('bpmScale').domain(); + }), + beatScale: Ember.computed('beatScaleDomain', 'beatScaleRange', function() { + return d3.scale.linear() + .domain(this.get('beatScaleDomain') || []) + .range(this.get('beatScaleRange') || []); + }), + quantizeBeatScale: Ember.computed('beatScale', function() { + const beatScale = this.get('beatScale'); + + return d3.scale.linear() + .domain(beatScale.range()) + .rangeRound(beatScale.range().map(Math.round)); + }), + + // Bar Scale + // domain is time [s] + // range is beats [b] + barScaleRange: Ember.computed('beatScaleRange', 'timeSignature', function() { + const { beatScale, timeSignature } = this.getProperties('beatScale', 'timeSignature'); + + return beatScale.range().map((n) => n / timeSignature); + }), + barScale: Ember.computed('beatScaleDomain', 'barScaleRange', function() { + return d3.scale.linear() + .domain(this.get('beatScaleDomain') || []) + .range(this.get('barScaleRange') || []); + }), + quantizeBarScale: Ember.computed('barScale', function() { + const barScale = this.get('barScale'); + + return d3.scale.linear() + .domain(barScale.range()) + .rangeRound(barScale.range().map(Math.round)); + }), + + toString() { + return ''; + }, +}); + +// provides dynamically updating beat grid properties +// supports constants and paths +function beatGridPropertyGenerator(beatGridFunctionName) { + return function(beatGridPath, unitOrPath) { + const isPath = !isValidNumber(unitOrPath); + + const getUnit = function(context) { + return isPath ? context.get(unitOrPath) : unitOrPath; + } + + return Ember.computed(`${beatGridPath}.beatScale`, isPath ? unitOrPath : '', { + get() { + const unit = getUnit(this); + const beatGrid = this.get(beatGridPath); + + return beatGrid && beatGrid[beatGridFunctionName](unit); + }, + + // TODO(TECHDEBT): this only works for timeToBeat + set(key, beat) { + // Ember.Logger.log(`set ${beatGridFunctionName}`, beat); + Ember.assert('Must set `${beatGridFunctionName} to valid number', isValidNumber(beat)); + + const beatGrid = this.get(beatGridPath); + const time = beatGrid && beatGrid.beatToTime(beat); + + this.set(unitOrPath, time); + + return beat; + }, + }); + }; +} + +// beat | time +export const computedBeatToTime = beatGridPropertyGenerator('beatToTime'); +export const computedTimeToBeat = beatGridPropertyGenerator('timeToBeat'); + +// beat | bar +export const computedBeatToBar = beatGridPropertyGenerator('beatToBar'); +export const computedBarToBeat = beatGridPropertyGenerator('barToBeat'); + +// bar | time +export const computedBarToTime = beatGridPropertyGenerator('barToTime'); +export const computedTimeToBar = beatGridPropertyGenerator('timeToBar'); + +// quantize +export const computedQuantizeBeat = beatGridPropertyGenerator('quantizeBeat'); +export const computedQuantizeBar = beatGridPropertyGenerator('quantizeBar'); + diff --git a/app/mixins/playable-arrangement/clip.js b/app/mixins/playable-arrangement/clip.js index 8737a518..6b1a73b7 100644 --- a/app/mixins/playable-arrangement/clip.js +++ b/app/mixins/playable-arrangement/clip.js @@ -29,6 +29,7 @@ export default Ember.Mixin.create(Ember.Evented, { session: Ember.inject.service(), fakeAudioContext: Ember.computed.reads('session.audioContext'), fakeMetronome: Ember.computed(function() { + // TODO(MULTIGRID) fix this return Metronome.create({ arrangement: { bpm: 128 } }); @@ -53,12 +54,12 @@ export default Ember.Mixin.create(Ember.Evented, { // returns absolute start time of this clip from metronome's frame of reference getAbsoluteStartTime() { - return this.get('metronome').beatToTime(this.get('startBeat')); + return this.get('metronome').beatToAbsTime(this.get('startBeat')); }, // returns absolute start time of this clip from metronome's frame of reference getAbsoluteEndTime() { - return this.get('metronome').beatToTime(this.get('endBeat')); + return this.get('metronome').beatToAbsTime(this.get('endBeat')); }, // returns absolute time from metronome's frame of reference @@ -67,9 +68,8 @@ export default Ember.Mixin.create(Ember.Evented, { }, // duration of clip in [s] - // TODO(MULTIGRID) - duration: Ember.computed('metronome.bpm', 'startBeat', 'beatCount', function() { - return this.get('metronome').getDuration(this.get('startBeat'), this.get('beatCount')); + duration: Ember.computed('arrangement.beatGrid', 'startBeat', 'endBeat', function() { + return this.get('arrangement.beatGrid').getDuration(this.get('startBeat'), this.get('endBeat')); }), endBeat: add('startBeat', 'beatCount'), @@ -97,7 +97,7 @@ export default Ember.Mixin.create(Ember.Evented, { // TODO(REFACTOR): turn isValid into validness mixin? isValid: Ember.computed.and('isValidStartBeat', 'isValidEndBeat', 'isValidBeatCount'), - clipScheduleDidChange: Ember.observer('isValid', 'isDisabled', 'startBeat', 'beatCount', 'duration', 'metronome.{absSeekTime,isPlaying,bpm}', function() { + clipScheduleDidChange: Ember.observer('isValid', 'isDisabled', 'startBeat', 'beatCount', 'duration', 'metronome.{absSeekTime,isPlaying}', 'arrangement.beatGrid', function() { this.set('isScheduled', this.get('metronome.isPlaying')); Ember.run.once(this, 'triggerScheduleEvents'); }), diff --git a/app/mixins/playable-arrangement/metronome.js b/app/mixins/playable-arrangement/metronome.js index 6f6c0189..5571f488 100644 --- a/app/mixins/playable-arrangement/metronome.js +++ b/app/mixins/playable-arrangement/metronome.js @@ -3,22 +3,20 @@ import Ember from 'ember'; import _ from 'npm:underscore'; import RequireAttributes from 'linx/lib/require-attributes'; -import { beatToTime, timeToBeat, clamp, isNumber, isValidNumber } from 'linx/lib/utils'; +import { clamp, isNumber, isValidNumber } from 'linx/lib/utils'; import Clock from 'linx/lib/clock'; // Holds rhythym based on clock -// TODO(REFACTOR): TODO(MULTIGRID): refactor metronome to have a beatgrid? export default Ember.Object.extend( Ember.Evented, { // required params audioContext: null, - arrangement: null, + beatGrid: null, // params seekBeat: 0, // [b] last seeked beat absSeekTime: 0, // [s] time of last seek in clock frame of reference - bpm: 128.0, isPlaying: false, // clock: Ember.computed('audioContext', function() { @@ -44,24 +42,9 @@ export default Ember.Object.extend( // returns WAAclock event // callbackAtBeat(callback, beat) { - // return this.callbackAtTime(callback, this.beatToTime(beat)); + // return this.callbackAtTime(callback, this.beatToAbsTime(beat)); // }, - // TODO(V2): clean this up - _updateBpm: Ember.observer('arrangement.bpm', function() { - const bpm = this.get('arrangement.bpm'); - - if (isValidNumber(bpm)) { - this.seekToBeat(this.getCurrentBeat()); - this.set('bpm', bpm); - } - }).on('init'), - - // TODO(MULTIRGID) TODO(REFACTOR) - getDuration(startBeat, beatCount) { - return beatToTime(beatCount, this.get('bpm')); - }, - seekToBeat(beat) { // Ember.Logger.log("metronome seekToBeat", beat); @@ -111,12 +94,11 @@ export default Ember.Object.extend( } }, - // TODO(MULTIGRID): turn into beatgrid // returns absolute time at which given beat will occur in audioContext - beatToTime(beat) { + beatToAbsTime(beat) { beat -= this.getCurrentBeat(); - return this.getAbsTime() + beatToTime(beat, this.get('bpm')); + return this.getAbsTime() + this.get('beatGrid').beatToTime(beat); }, // Returns current metronome beat @@ -137,7 +119,11 @@ export default Ember.Object.extend( }, _getPlayedBeats() { - return timeToBeat(this._getPlayedTime(), this.get('bpm')); + const beatGrid = this.get('beatGrid'); + const startBeat = this.get('seekBeat'); + const startTime = beatGrid.beatToTime(startBeat); + const endTime = startTime + this._getPlayedTime(); + return beatGrid.getBeatCount(startTime, endTime); }, destroy() { diff --git a/app/mixins/playable-arrangement/track-clip.js b/app/mixins/playable-arrangement/track-clip.js index e808596a..df3d85bf 100644 --- a/app/mixins/playable-arrangement/track-clip.js +++ b/app/mixins/playable-arrangement/track-clip.js @@ -1,9 +1,10 @@ import Ember from 'ember'; +import d3 from 'd3'; + import RequireAttributes from 'linx/lib/require-attributes'; import AutomatableClipMixin from './automatable-clip'; import PlayableClipMixin from './clip'; -import TrackSourceNode from 'linx/lib/web-audio/track-source-node'; import GainNode from 'linx/lib/web-audio/gain-node'; import TunaDelayNode from 'linx/lib/web-audio/tuna/delay-node'; import TunaFilterNode from 'linx/lib/web-audio/tuna/filter-node'; @@ -23,6 +24,7 @@ import { import { default as AutomatableClipControlMixin, CONTROL_TYPE_VOLUME, + CONTROL_TYPE_TEMPO, CONTROL_TYPE_LOW_BAND, CONTROL_TYPE_MID_BAND, CONTROL_TYPE_HIGH_BAND, @@ -35,19 +37,35 @@ import { CONTROL_TYPE_FILTER_LOWPASS_Q, } from './automatable-clip/control'; -// TODO(CLEANUP): nest under track-clip/controls/gain? +function _createFilterCutoffScale() { + return d3.scale.log().domain([20, 22050]).range([0, 1]); +} +function _createBandEqScale() { + return d3.scale.linear().domain([-40, 40]).range([0, 1]); +} +function _createFilterQScale() { + return d3.scale.linear().domain([0.001, 10]).range([0, 1]); +} + const TrackVolumeControl = Ember.Object.extend( new AutomatableClipControlMixin('trackVolumeNode.gain'), { type: CONTROL_TYPE_VOLUME, defaultValue: 1, }); +const TrackTempoControl = Ember.Object.extend( + AutomatableClipControlMixin('soundtouchNode.tempo'), { + + type: CONTROL_TYPE_TEMPO, + defaultValue: 1, +}); const TrackLowBandControl = Ember.Object.extend( new AutomatableClipControlMixin('lowBandEqNode.filter.gain'), { type: CONTROL_TYPE_LOW_BAND, defaultValue: 6, + valueScale: Ember.computed(() => _createBandEqScale()), }); const TrackMidBandControl = Ember.Object.extend( @@ -55,6 +73,7 @@ const TrackMidBandControl = Ember.Object.extend( type: CONTROL_TYPE_MID_BAND, defaultValue: 6, + valueScale: Ember.computed(() => _createBandEqScale()), }); const TrackHighBandControl = Ember.Object.extend( @@ -62,6 +81,7 @@ const TrackHighBandControl = Ember.Object.extend( type: CONTROL_TYPE_HIGH_BAND, defaultValue: 6, + valueScale: Ember.computed(() => _createBandEqScale()), }); const TrackPitchControl = Ember.Object.extend( @@ -83,6 +103,7 @@ const TrackDelayCutoffControl = Ember.Object.extend( type: CONTROL_TYPE_DELAY_CUTOFF, defaultValue: 2000, + valueScale: Ember.computed(() => _createFilterCutoffScale()), }); const TrackHighpassFilterCutoffControl = Ember.Object.extend( @@ -90,12 +111,14 @@ const TrackHighpassFilterCutoffControl = Ember.Object.extend( type: CONTROL_TYPE_FILTER_HIGHPASS_CUTOFF, defaultValue: 20, + valueScale: Ember.computed(() => _createFilterCutoffScale()), }); const TrackHighpassFilterQControl = Ember.Object.extend( new AutomatableClipControlMixin('tunaHighpassFilterNode.filter.Q'), { type: CONTROL_TYPE_FILTER_HIGHPASS_Q, defaultValue: 1, + valueScale: Ember.computed(() => _createFilterQScale()), }); const TrackLowpassFilterCutoffControl = Ember.Object.extend( @@ -103,12 +126,14 @@ const TrackLowpassFilterCutoffControl = Ember.Object.extend( type: CONTROL_TYPE_FILTER_LOWPASS_CUTOFF, defaultValue: 22050, + valueScale: Ember.computed(() => _createFilterCutoffScale()), }); const TrackLowpassFilterQControl = Ember.Object.extend( new AutomatableClipControlMixin('tunaLowpassFilterNode.filter.Q'), { type: CONTROL_TYPE_FILTER_LOWPASS_Q, defaultValue: 1, + valueScale: Ember.computed(() => _createFilterQScale()), }); @@ -133,6 +158,7 @@ export default Ember.Mixin.create( controls: Ember.computed(function() { return [ TrackVolumeControl.create({ clip: this }), + TrackTempoControl.create({ clip: this }), TrackLowBandControl.create({ clip: this }), TrackMidBandControl.create({ clip: this }), TrackHighBandControl.create({ clip: this }), @@ -167,7 +193,6 @@ export default Ember.Mixin.create( audioBeatCount: subtract('audioEndBeat', 'audioStartBeat'), audioDuration: subtract('audioEndTime', 'audioStartTime'), audioBarCount: subtract('audioEndBar', 'audioStartBar'), - audioBpm: Ember.computed.reads('audioMeta.bpm'), getCurrentAudioBeat() { const currentClipBeat = this.getCurrentClipBeat(); @@ -190,21 +215,13 @@ export default Ember.Mixin.create( return audioBeatGrid.beatToTime(audioStartBeat + clipBeat); }, - // TODO(V2): dynamic tempo - audioScheduleDidChange: Ember.observer('audioBinary.isReady', 'audioStartBeat', 'audioBeatCount', 'tempo', 'transpose', 'gain', function() { + audioScheduleDidChange: Ember.observer('audioBinary.isReady', 'audioStartBeat', 'audioBeatCount', 'transpose', 'gain', function() { Ember.run.once(this, 'startSource'); }).on('schedule'), - tempo: Ember.computed('syncBpm', 'audioBpm', function() { - const syncBpm = this.get('syncBpm'); - const audioBpm = this.get('audioBpm'); - - return (isValidNumber(syncBpm) && isValidNumber(audioBpm)) ? (syncBpm / audioBpm) : 1; - }), - startSource() { if (this.get('isScheduled') && this.get('audioBinary.isReady')) { - const { tempo, transpose } = this.getProperties('tempo', 'transpose'); + const { transpose } = this.getProperties('transpose'); // if starting in past, start now instead let startTime = Math.max(this.getAbsoluteTime(), this.getAbsoluteStartTime()); let offsetTime = this.getCurrentAudioTime(); @@ -216,9 +233,9 @@ export default Ember.Mixin.create( offsetTime = 0; } - Ember.Logger.log('startTrack', this.get('track.title'), startTime, offsetTime, endTime, tempo, transpose); + Ember.Logger.log('startTrack', this.get('track.title'), startTime, offsetTime, endTime, transpose); const node = this.get('soundtouchNode'); - node && node.start(startTime, offsetTime, endTime, tempo, transpose); + node && node.start(startTime, offsetTime, endTime, transpose); } else { this.stopSource(); } @@ -233,15 +250,6 @@ export default Ember.Mixin.create( // // Web Audio Nodes // - // TODO(REFACTOR): how to distinguish between track gain, fx gain, arrangement gain? - // TODO(REFACTOR): set GainControl.defaultValue based on track.audioMeta.loudness - // that might mean making a specific TrackGainNode? - // trackSourceNode: computedObject(TrackSourceNode, { - // 'audioContext': 'audioContext', - // 'track': 'track', - // 'outputNode': 'outputNode.content', - // }), - soundtouchNode: computedObject(SoundtouchNode, { 'audioContext': 'audioContext', 'audioBuffer': 'audioBinary.audioBuffer', diff --git a/app/models/arrangement/automation-clip.js b/app/models/arrangement/automation-clip.js index c1d00bf3..771c82de 100644 --- a/app/models/arrangement/automation-clip.js +++ b/app/models/arrangement/automation-clip.js @@ -118,7 +118,7 @@ export default Clip.extend( values = values.slice(index, values.length); } - Ember.Logger.log('updateControl', targetControl.get('type'), startTime, duration); + // Ember.Logger.log('updateControl', targetControl.get('type'), startTime, duration); targetControl.addAutomation(this, { values, startTime, diff --git a/app/models/mix.js b/app/models/mix.js index 22575660..56973ab8 100644 --- a/app/models/mix.js +++ b/app/models/mix.js @@ -2,36 +2,73 @@ import Ember from 'ember'; import DS from 'ember-data'; import _ from 'npm:underscore'; +import d3 from 'd3'; import CrudMixin from 'linx/mixins/models/crud'; import OrderedHasManyMixin from 'linx/mixins/models/ordered-has-many'; import PlayableArrangementMixin from 'linx/mixins/playable-arrangement'; +import { flatten, isValidNumber } from 'linx/lib/utils'; export default DS.Model.extend( CrudMixin, PlayableArrangementMixin, - OrderedHasManyMixin('_mixItems'), { + new OrderedHasManyMixin('_mixItems'), { // implement ordered has many orderedHasManyItemModelName: 'mix/item', _mixItems: DS.hasMany('mix/item', { async: true }), title: DS.attr('string'), - bpm: DS.attr('number', { defaultValue: 128 }), timeSignature: DS.attr('number', { defaultValue: 4.0 }), + // DEPRECATED, from pre-multigrid + bpm: DS.attr('number', { defaultValue: 128 }), + // implement playable-arrangement session: Ember.inject.service(), audioContext: Ember.computed.reads('session.audioContext'), + bpmControlPoints: Ember.computed('bpm', + '_transitionBpmControlPoints.@each.{beat,value,transitionStartBeat}', function() { + const bpmControlPoints = this.get('_transitionBpmControlPoints'); + + // if we dont have any bpm control points, fake it with base mix bpm + if (Ember.isEmpty(bpmControlPoints)) { + const mixBpm = this.get('bpm'); + + // because the bpmScale is clamped, this sets a constant bpm + return [ + { + beat: 0, + value: mixBpm, + }, + ]; + } else { + return bpmControlPoints.map((controlPoint) => { + // offset control point by transitionClip startBeat + const transitionStartBeat = controlPoint.get('transitionStartBeat'); + const beat = isValidNumber(transitionStartBeat) ? + controlPoint.get('beat') + transitionStartBeat : controlPoint.get('beat'); + + return { + beat, + value: controlPoint.get('value'), + }; + }); + } + }), - tracks: Ember.computed.mapBy('items', 'track.content'), - transitions: Ember.computed.mapBy('items', 'transition.content'), + tracks: Ember.computed.mapBy('items', 'track'), + transitions: Ember.computed.mapBy('items', 'transition'), trackClips: Ember.computed.mapBy('items', 'trackClip'), transitionClips: Ember.computed.mapBy('items', 'transitionClip'), clips: Ember.computed.uniq('trackClips', 'transitionClips'), + _transitionBpmControlPoints: Ember.computed('transitions.@each.bpmControlPoints', function() { + return flatten(this.get('transitions').without(undefined).mapBy('bpmControlPoints')).without(undefined); + }), + trackAt(index) { const item = this.objectAt(index); return item && item.get('track.content'); @@ -55,7 +92,6 @@ export default DS.Model.extend( return item.setTrack(track); }); - // TODO(REFACTOR2): possible bug with items being proxies return Ember.RSVP.all(items).then((items) => { return this.replace(index, 0, items); }); diff --git a/app/models/mix/transition.js b/app/models/mix/transition.js index eaa2116d..0dae3a4b 100644 --- a/app/models/mix/transition.js +++ b/app/models/mix/transition.js @@ -6,13 +6,15 @@ import _ from 'npm:underscore'; import ReadinessMixin from 'linx/mixins/readiness'; import PlayableArrangementMixin from 'linx/mixins/playable-arrangement'; import DependentRelationshipMixin from 'linx/mixins/models/dependent-relationship'; +import withDefaultModel from 'linx/lib/computed/with-default-model'; import { CONTROL_TYPE_VOLUME, + CONTROL_TYPE_BPM, + CONTROL_TYPE_DELAY_WET, CONTROL_TYPE_LOW_BAND, CONTROL_TYPE_MID_BAND, CONTROL_TYPE_HIGH_BAND, - CONTROL_TYPE_DELAY_WET, CONTROL_TYPE_DELAY_CUTOFF, CONTROL_TYPE_FILTER_HIGHPASS_CUTOFF, CONTROL_TYPE_FILTER_HIGHPASS_Q, @@ -23,26 +25,52 @@ import { isValidNumber } from 'linx/lib/utils'; export default DS.Model.extend( PlayableArrangementMixin, - DependentRelationshipMixin('fromTrackAutomationClips'), - DependentRelationshipMixin('toTrackAutomationClips'), - ReadinessMixin('isTransitionReady'), { + new DependentRelationshipMixin('fromTrackAutomationClips'), + new DependentRelationshipMixin('toTrackAutomationClips'), + new DependentRelationshipMixin('fromTrackTempoClip'), + new DependentRelationshipMixin('toTrackTempoClip'), + new DependentRelationshipMixin('bpmClip'), + new ReadinessMixin('isTransitionReady'), { title: DS.attr('string'), description: DS.attr('string'), beatCount: DS.attr('number', { defaultValue: 16 }), transitionClip: DS.belongsTo('mix/transition-clip'), + bpmClip: DS.belongsTo('mix/transition/automation-clip'), fromTrackClip: Ember.computed.reads('transitionClip.fromTrackClip'), toTrackClip: Ember.computed.reads('transitionClip.toTrackClip'), + _fromTrackTempoClip: DS.belongsTo('mix/transition/from-track-tempo-clip', { async: true }), + fromTrackTempoClip: withDefaultModel('_fromTrackTempoClip', function() { + return this.get('store').createRecord('mix/transition/from-track-tempo-clip', { + transition: this, + }); + }), + + _toTrackTempoClip: DS.belongsTo('mix/transition/to-track-tempo-clip', { async: true }), + toTrackTempoClip: withDefaultModel('_toTrackTempoClip', function() { + return this.get('store').createRecord('mix/transition/to-track-tempo-clip', { + transition: this, + }); + }), + fromTrackAutomationClips: DS.hasMany('mix/transition/from-track-automation-clip'), toTrackAutomationClips: DS.hasMany('mix/transition/to-track-automation-clip'), // implementing PlayableArrangement audioContext: Ember.computed.reads('transitionClip.audioContext'), outputNode: Ember.computed.reads('transitionClip.outputNode.content'), - clips: Ember.computed.uniq('fromTrackAutomationClips', 'toTrackAutomationClips'), - bpm: Ember.computed.reads('transitionClip.mix.bpm'), + clips: Ember.computed.uniq('fromTrackAutomationClips', 'toTrackAutomationClips', '_tempoClips'), + bpmControlPoints: Ember.computed.reads('bpmClip.controlPoints'), + + _tempoClips: Ember.computed('bpmClip', 'fromTrackTempoClip', 'toTrackTempoClip', function() { + return [ + this.get('bpmClip'), + this.get('fromTrackTempoClip'), + this.get('toTrackTempoClip'), + ].filter((clip) => !!clip); + }), // optimizes this transition, with given constraints // TODO(REFACTOR2): rethink this. convert to ember-concurrency @@ -168,7 +196,12 @@ export default DS.Model.extend( toTrackLowpassQClip ]; - const clips = fromTrackAutomationClips.concat(toTrackAutomationClips); + const bpmClip = store.createRecord('mix/transition/automation-clip', { + controlType: CONTROL_TYPE_BPM, + transition: this, + }); + + const clips = fromTrackAutomationClips.concat(toTrackAutomationClips).concat([bpmClip]); // TODO(TECHDEBT): save automation clips BEFORE adding items. otherwise, we get a weird bug // where control points are removed from relationship while saving, if only one has changed @@ -312,8 +345,26 @@ export default DS.Model.extend( this.set('fromTrackClip.delayBypass', true); } + // TODO(TRACKMULTIGRID): needs update + let fromTrackBpm = this.get('fromTrackClip.track.audioMeta.bpm'); + fromTrackBpm = isValidNumber(fromTrackBpm) ? fromTrackBpm : 128; + let toTrackBpm = this.get('fromTrackClip.track.audioMeta.bpm'); + toTrackBpm = isValidNumber(toTrackBpm) ? toTrackBpm : fromTrackBpm; + + bpmClip.addControlPoints([ + { + beat: 0, + value: fromTrackBpm, + }, + { + beat: beatCount, + value: toTrackBpm, + } + ]); + this.get('fromTrackAutomationClips').addObjects(fromTrackAutomationClips); this.get('toTrackAutomationClips').addObjects(toTrackAutomationClips); + this.set('bpmClip', bpmClip); this.set('beatCount', beatCount); return this; @@ -337,7 +388,8 @@ export default DS.Model.extend( destroyAutomationClips() { return Ember.RSVP.all([ this.destroyFromTrackAutomationClips(), - this.destroyToTrackAutomationClips() + this.destroyToTrackAutomationClips(), + this.get('bpmClip').then((clip) => clip && clip.destroyRecord()) ]); }, }); diff --git a/app/models/mix/transition/automation-clip.js b/app/models/mix/transition/automation-clip.js index 5c595773..c5d1f415 100644 --- a/app/models/mix/transition/automation-clip.js +++ b/app/models/mix/transition/automation-clip.js @@ -15,14 +15,14 @@ export default AutomationClip.extend({ _controlPoints: DS.hasMany('mix/transition/automation-clip/control-point', { async: true }), arrangement: Ember.computed.reads('transition'), - beatCount: Ember.computed.reads('transition.beatCount'), + transitionBeatCount: Ember.computed.reads('transition.beatCount'), // transition automation-clip must have controlPoints within transition - _updateControlPoints: Ember.observer('beatCount', function() { - const beatCount = this.get('beatCount') - // Ember.Logger.log('_updateControlPoints', beatCount); + _updateControlPoints: Ember.observer('transitionBeatCount', function() { + const transitionBeatCount = this.get('transitionBeatCount') + // Ember.Logger.log('_updateControlPoints', transitionBeatCount); - if (isValidNumber(beatCount)) { + if (isValidNumber(transitionBeatCount)) { this.get('controlPoints').forEach((controlPoint) => { const oldBeat = controlPoint.get('beat'); @@ -30,9 +30,9 @@ export default AutomationClip.extend({ if (controlPoint.get('isFirstItem')) { newBeat = 0; } else if (controlPoint.get('isLastItem')) { - newBeat = beatCount; + newBeat = transitionBeatCount; } else { - newBeat = clamp(0, oldBeat, beatCount); + newBeat = clamp(0, oldBeat, transitionBeatCount); } controlPoint.set('beat', newBeat); diff --git a/app/models/mix/transition/automation-clip/control-point.js b/app/models/mix/transition/automation-clip/control-point.js index 660139c5..4d65e9ad 100644 --- a/app/models/mix/transition/automation-clip/control-point.js +++ b/app/models/mix/transition/automation-clip/control-point.js @@ -13,6 +13,8 @@ export default ArrangementAutomationClipControlPoint.extend({ return this._super.apply(this, arguments); }, + transitionStartBeat: Ember.computed.reads('automationClip.transition.transitionClip.startBeat'), + save() { if (isValidNumber(this.get('beat')) && isValidNumber(this.get('value'))) { // console.log("save mix control point", this.get('id')); diff --git a/app/models/mix/transition/from-track-automation-clip.js b/app/models/mix/transition/from-track-automation-clip.js index 97bf6b33..f272542f 100644 --- a/app/models/mix/transition/from-track-automation-clip.js +++ b/app/models/mix/transition/from-track-automation-clip.js @@ -4,7 +4,6 @@ import DS from 'ember-data'; import MixTransitionAutomationClip from './automation-clip'; export default MixTransitionAutomationClip.extend({ - transition: DS.belongsTo('mix/transition'), // overrides targetClip: Ember.computed.reads('transition.fromTrackClip'), diff --git a/app/models/mix/transition/from-track-tempo-clip.js b/app/models/mix/transition/from-track-tempo-clip.js new file mode 100644 index 00000000..87b822ae --- /dev/null +++ b/app/models/mix/transition/from-track-tempo-clip.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import MixTransitionTrackTempoClip from './track-tempo-clip'; + +export default MixTransitionTrackTempoClip.extend({ + + // overrides + targetClip: Ember.computed.reads('transition.fromTrackClip'), +}); diff --git a/app/models/mix/transition/to-track-automation-clip.js b/app/models/mix/transition/to-track-automation-clip.js index 8feb4032..d6976edc 100644 --- a/app/models/mix/transition/to-track-automation-clip.js +++ b/app/models/mix/transition/to-track-automation-clip.js @@ -4,7 +4,6 @@ import DS from 'ember-data'; import MixTransitionAutomationClip from './automation-clip'; export default MixTransitionAutomationClip.extend({ - transition: DS.belongsTo('mix/transition'), // overrides targetClip: Ember.computed.reads('transition.toTrackClip'), diff --git a/app/models/mix/transition/to-track-tempo-clip.js b/app/models/mix/transition/to-track-tempo-clip.js new file mode 100644 index 00000000..a41cce91 --- /dev/null +++ b/app/models/mix/transition/to-track-tempo-clip.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import MixTransitionTrackTempoClip from './track-tempo-clip'; + +export default MixTransitionTrackTempoClip.extend({ + + // overrides + targetClip: Ember.computed.reads('transition.toTrackClip'), +}); diff --git a/app/models/mix/transition/track-tempo-clip.js b/app/models/mix/transition/track-tempo-clip.js new file mode 100644 index 00000000..89a66b5a --- /dev/null +++ b/app/models/mix/transition/track-tempo-clip.js @@ -0,0 +1,24 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +import MixTransitionAutomationClip from './automation-clip'; +import { CONTROL_TYPE_TEMPO } from 'linx/mixins/playable-arrangement/automatable-clip/control'; + +export default MixTransitionAutomationClip.extend({ + + // overrides + targetClip: Ember.computed.reads('transition.toTrackClip'), + controlType: CONTROL_TYPE_TEMPO, + startBeat: 0, + endBeat: Ember.computed.reads('transition.beatCount'), + + // map the transition's bpmScale onto track grid + // TODO(TRACKMULTIGRID): audioBpm not constant + scale: null, // compute off mixBpmScale, divide by audioBpm + + startValue: 0, // compute off startBeat, scale + endValue: 0, // compute off endBeat, scale + + + transitionBpmScale: null, +}); diff --git a/app/models/track.js b/app/models/track.js index 01f389f7..641d0370 100644 --- a/app/models/track.js +++ b/app/models/track.js @@ -117,7 +117,7 @@ export default DS.Model.extend( this.get('audioMeta.content').setProperties({ barGridTime, - tempo: parseFloat(meta.overall_tempo_straight), + bpm: parseFloat(meta.overall_tempo_straight), timeSignature: parseInt(meta.clicks_per_bar), }); diff --git a/app/models/track/audio-meta.js b/app/models/track/audio-meta.js index dfa16b24..32867133 100644 --- a/app/models/track/audio-meta.js +++ b/app/models/track/audio-meta.js @@ -110,11 +110,11 @@ export default DS.Model.extend( duration: 'duration', bpm: 'bpm', timeSignature: 'timeSignature', - // TODO(MULTIGRID): make this use real thing + // TODO(TRACKMULTIGRID): make this use real thing barGridTime: 0, }), - // TODO(MULTIGRID): adapt for multiple grid markers. Piecewise-Scale? or a long domain/range? + // TODO(TRACKMULTIGRID): adapt for multiple grid markers. Piecewise-Scale? or a long domain/range? nudge(value) { Ember.assert('Cannot nudge AudioMeta.barGridTime without numeric value', isValidNumber(value)); diff --git a/app/models/track/audio-meta/beat-grid.js b/app/models/track/audio-meta/beat-grid.js index 2d53e522..c2aae774 100644 --- a/app/models/track/audio-meta/beat-grid.js +++ b/app/models/track/audio-meta/beat-grid.js @@ -123,13 +123,13 @@ export default Ember.Object.extend({ 'range': 'quantizeBarScaleRange', }), - // TODO(MULTIGRID): this will depend on the grid markers and bpm + // TODO(TRACKMULTIGRID): this will depend on the grid markers and bpm beatCount: Ember.computed('duration', 'bpm', function() { return timeToBeat(this.get('duration'), this.get('bpm')); }), // the time of the first actual beat in the raw audio file - // TODO(MULTIGRID): this supposes a constant bpm in the audio file + // TODO(TRACKMULTIGRID): this supposes a constant bpm in the audio file firstBarOffset: Ember.computed('barGridTime', 'bpm', 'timeSignature', function() { const bpm = this.get('bpm'); const timeSignature = this.get('timeSignature'); diff --git a/tests/unit/models/audio-meta/beat-grid-test.js b/tests/unit/models/audio-meta/beat-grid-test.js index 1715fbeb..5fb7d747 100644 --- a/tests/unit/models/audio-meta/beat-grid-test.js +++ b/tests/unit/models/audio-meta/beat-grid-test.js @@ -78,7 +78,6 @@ describe('BeatGrid', function() { }); }); - // TODO(MULTIGRID): rework describe('#nudge', function() { let nudgeAmount = 0.005, previousStart; diff --git a/tests/unit/models/track/audio-meta-test.js b/tests/unit/models/track/audio-meta-test.js index d3ecbb0d..e3bfee11 100644 --- a/tests/unit/models/track/audio-meta-test.js +++ b/tests/unit/models/track/audio-meta-test.js @@ -61,7 +61,6 @@ describe('AudioMetaModel', function() { lastWholeBeat: 776, lastWholeBar: 194, - // TODO(MULTIGRID): this needs to change 'sortedGridMarkers.length': 1, 'sortedSectionMarkers.length': function() { return analysis.get('confidentSections.length'); }, }); From dd42793f35e91f63579545f099f82de93333fa98 Mon Sep 17 00:00:00 2001 From: wolfbiter Date: Sun, 23 Oct 2016 12:47:42 -0700 Subject: [PATCH 6/6] BLOCKED: computed properties not updating correctly WIP: converting to remove bpmClip from transition --- app/mixins/playable-arrangement.js | 2 +- .../automatable-clip/control.js | 2 - app/models/mix.js | 64 +++++++++---------- app/models/mix/transition.js | 47 +++++++------- .../automation-clip/control-point.js | 2 - 5 files changed, 55 insertions(+), 62 deletions(-) diff --git a/app/mixins/playable-arrangement.js b/app/mixins/playable-arrangement.js index cf3dce64..4b9d64b2 100644 --- a/app/mixins/playable-arrangement.js +++ b/app/mixins/playable-arrangement.js @@ -16,7 +16,7 @@ export default Ember.Mixin.create({ // required params clips: null, - bpmControlPoints: null, + bpmControlPoints: null, // or bpmScale audioContext: null, // optional params diff --git a/app/mixins/playable-arrangement/automatable-clip/control.js b/app/mixins/playable-arrangement/automatable-clip/control.js index f4ba73b4..b65ac9a3 100644 --- a/app/mixins/playable-arrangement/automatable-clip/control.js +++ b/app/mixins/playable-arrangement/automatable-clip/control.js @@ -8,7 +8,6 @@ import { isValidNumber } from 'linx/lib/utils'; export const CONTROL_TYPE_VOLUME = 'gain'; export const CONTROL_TYPE_TEMPO = 'tempo'; -export const CONTROL_TYPE_BPM = 'bpm'; // currently only exists in transition export const CONTROL_TYPE_PITCH = 'pitch'; export const CONTROL_TYPE_LOW_BAND = 'low-band'; @@ -25,7 +24,6 @@ export const CONTROL_TYPE_FILTER_LOWPASS_Q = 'filter-lowpass-q'; export const CONTROL_TYPES = [ CONTROL_TYPE_VOLUME, - CONTROL_TYPE_BPM, CONTROL_TYPE_TEMPO, CONTROL_TYPE_PITCH, CONTROL_TYPE_DELAY_WET, diff --git a/app/models/mix.js b/app/models/mix.js index 56973ab8..cdb9482d 100644 --- a/app/models/mix.js +++ b/app/models/mix.js @@ -23,39 +23,43 @@ export default DS.Model.extend( timeSignature: DS.attr('number', { defaultValue: 4.0 }), // DEPRECATED, from pre-multigrid - bpm: DS.attr('number', { defaultValue: 128 }), + bpm: DS.attr('number'), // implement playable-arrangement session: Ember.inject.service(), audioContext: Ember.computed.reads('session.audioContext'), bpmControlPoints: Ember.computed('bpm', - '_transitionBpmControlPoints.@each.{beat,value,transitionStartBeat}', function() { - const bpmControlPoints = this.get('_transitionBpmControlPoints'); - - // if we dont have any bpm control points, fake it with base mix bpm - if (Ember.isEmpty(bpmControlPoints)) { - const mixBpm = this.get('bpm'); - - // because the bpmScale is clamped, this sets a constant bpm - return [ - { - beat: 0, - value: mixBpm, - }, - ]; - } else { - return bpmControlPoints.map((controlPoint) => { - // offset control point by transitionClip startBeat - const transitionStartBeat = controlPoint.get('transitionStartBeat'); - const beat = isValidNumber(transitionStartBeat) ? - controlPoint.get('beat') + transitionStartBeat : controlPoint.get('beat'); - - return { - beat, - value: controlPoint.get('value'), - }; - }); - } + 'transitions.@each.{startBpm,endBpm}', + 'transitionClips.@each.{startBeat,endBeat}', + function() { + + // legacy default to 'bpm' if using a mix with bpm + if (this.get('bpm')) { + const mixBpm = this.get('bpm'); + + // because the bpmScale is clamped, this sets a constant bpm + return [ + { + beat: 0, + value: mixBpm, + }, + ]; + } else { + return this.get('transitions') + .reject((transition) => !transition) + .reduce((controlPoints, transition) => { + controlPoints.addObject({ + beat: transition.get('transitionClip.startBeat'), + value: transition.get('startBpm'), + }); + controlPoints.addObject({ + beat: transition.get('transitionClip.endBeat'), + value: transition.get('endBpm'), + }); + + return controlPoints; + }, []); + } }), tracks: Ember.computed.mapBy('items', 'track'), @@ -65,10 +69,6 @@ export default DS.Model.extend( transitionClips: Ember.computed.mapBy('items', 'transitionClip'), clips: Ember.computed.uniq('trackClips', 'transitionClips'), - _transitionBpmControlPoints: Ember.computed('transitions.@each.bpmControlPoints', function() { - return flatten(this.get('transitions').without(undefined).mapBy('bpmControlPoints')).without(undefined); - }), - trackAt(index) { const item = this.objectAt(index); return item && item.get('track.content'); diff --git a/app/models/mix/transition.js b/app/models/mix/transition.js index 0dae3a4b..6a789cc7 100644 --- a/app/models/mix/transition.js +++ b/app/models/mix/transition.js @@ -7,10 +7,13 @@ import ReadinessMixin from 'linx/mixins/readiness'; import PlayableArrangementMixin from 'linx/mixins/playable-arrangement'; import DependentRelationshipMixin from 'linx/mixins/models/dependent-relationship'; import withDefaultModel from 'linx/lib/computed/with-default-model'; +import { + default as withDefault, + withDefaultProperty +} from 'linx/lib/computed/with-default'; import { CONTROL_TYPE_VOLUME, - CONTROL_TYPE_BPM, CONTROL_TYPE_DELAY_WET, CONTROL_TYPE_LOW_BAND, CONTROL_TYPE_MID_BAND, @@ -29,15 +32,21 @@ export default DS.Model.extend( new DependentRelationshipMixin('toTrackAutomationClips'), new DependentRelationshipMixin('fromTrackTempoClip'), new DependentRelationshipMixin('toTrackTempoClip'), - new DependentRelationshipMixin('bpmClip'), new ReadinessMixin('isTransitionReady'), { title: DS.attr('string'), description: DS.attr('string'), beatCount: DS.attr('number', { defaultValue: 16 }), transitionClip: DS.belongsTo('mix/transition-clip'), + _startBpm: DS.attr('number'), + _endBpm: DS.attr('number'), + + startBpm: withDefaultProperty('_startBpm', '_defaultStartBpm'), + endBpm: withDefaultProperty('_endBpm', '_defaultEndBpm'), + + _defaultStartBpm: withDefault('fromTrackClip.track.audioMeta.bpm', 128), + _defaultEndBpm: withDefaultProperty('toTrackClip.track.audioMeta.bpm', '_defaultStartBpm'), - bpmClip: DS.belongsTo('mix/transition/automation-clip'), fromTrackClip: Ember.computed.reads('transitionClip.fromTrackClip'), toTrackClip: Ember.computed.reads('transitionClip.toTrackClip'), @@ -62,11 +71,10 @@ export default DS.Model.extend( audioContext: Ember.computed.reads('transitionClip.audioContext'), outputNode: Ember.computed.reads('transitionClip.outputNode.content'), clips: Ember.computed.uniq('fromTrackAutomationClips', 'toTrackAutomationClips', '_tempoClips'), - bpmControlPoints: Ember.computed.reads('bpmClip.controlPoints'), + bpmScale: Ember.computed.reads('transitionClip.mixItem.mix.bpmScale'), - _tempoClips: Ember.computed('bpmClip', 'fromTrackTempoClip', 'toTrackTempoClip', function() { + _tempoClips: Ember.computed('fromTrackTempoClip', 'toTrackTempoClip', function() { return [ - this.get('bpmClip'), this.get('fromTrackTempoClip'), this.get('toTrackTempoClip'), ].filter((clip) => !!clip); @@ -196,12 +204,7 @@ export default DS.Model.extend( toTrackLowpassQClip ]; - const bpmClip = store.createRecord('mix/transition/automation-clip', { - controlType: CONTROL_TYPE_BPM, - transition: this, - }); - - const clips = fromTrackAutomationClips.concat(toTrackAutomationClips).concat([bpmClip]); + const clips = fromTrackAutomationClips.concat(toTrackAutomationClips); // TODO(TECHDEBT): save automation clips BEFORE adding items. otherwise, we get a weird bug // where control points are removed from relationship while saving, if only one has changed @@ -351,20 +354,8 @@ export default DS.Model.extend( let toTrackBpm = this.get('fromTrackClip.track.audioMeta.bpm'); toTrackBpm = isValidNumber(toTrackBpm) ? toTrackBpm : fromTrackBpm; - bpmClip.addControlPoints([ - { - beat: 0, - value: fromTrackBpm, - }, - { - beat: beatCount, - value: toTrackBpm, - } - ]); - this.get('fromTrackAutomationClips').addObjects(fromTrackAutomationClips); this.get('toTrackAutomationClips').addObjects(toTrackAutomationClips); - this.set('bpmClip', bpmClip); this.set('beatCount', beatCount); return this; @@ -385,11 +376,17 @@ export default DS.Model.extend( }); }, + destroyToTrackAutomationClips() { + return this.get('_tempoClips').then((_tempoClips) => { + return Ember.RSVP.all(_tempoClips.toArray().map((clip) => clip.destroyRecord())); + }); + }, + destroyAutomationClips() { return Ember.RSVP.all([ this.destroyFromTrackAutomationClips(), this.destroyToTrackAutomationClips(), - this.get('bpmClip').then((clip) => clip && clip.destroyRecord()) + this.destroyTempoClips() ]); }, }); diff --git a/app/models/mix/transition/automation-clip/control-point.js b/app/models/mix/transition/automation-clip/control-point.js index 4d65e9ad..660139c5 100644 --- a/app/models/mix/transition/automation-clip/control-point.js +++ b/app/models/mix/transition/automation-clip/control-point.js @@ -13,8 +13,6 @@ export default ArrangementAutomationClipControlPoint.extend({ return this._super.apply(this, arguments); }, - transitionStartBeat: Ember.computed.reads('automationClip.transition.transitionClip.startBeat'), - save() { if (isValidNumber(this.get('beat')) && isValidNumber(this.get('value'))) { // console.log("save mix control point", this.get('id'));