diff --git a/app/lib/soundtouch.js b/app/lib/soundtouch.js index 09b75684..26e9559f 100644 --- a/app/lib/soundtouch.js +++ b/app/lib/soundtouch.js @@ -41,6 +41,7 @@ FifoSampleBuffer.prototype.clear = function() { // TODO(TECHDEBT): window.BUFFER_SIZE set by mix builder window.MAX_BUFFER_SIZE = 16384; window.BUFFER_SIZE = window.MAX_BUFFER_SIZE / 8; +const DSP_BUFFER_LENGTH = 128; const SAMPLE_DRIFT_TOLERANCE = 512; export function SoundtouchBufferSource(buffer) { @@ -69,8 +70,7 @@ SoundtouchBufferSource.prototype = { } }; -// TODO(TRACKMULTIGRID): audioBpm not a constant -export function createSoundtouchNode({ audioContext, filter, startTime, offsetTime, endTime, audioBpm, defaultPitch }) { +export function createSoundtouchNode({ audioContext, filter, startTime, offsetTime, endTime, defaultPitch }) { const channelCount = 2; const windowBufferSize = window.BUFFER_SIZE; @@ -80,8 +80,6 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi return; } - audioBpm = isValidNumber(audioBpm) ? audioBpm : 128; // TODO(TECHDEBT): share default bpm - const samples = new Float32Array(windowBufferSize * channelCount); const sampleRate = audioContext.sampleRate || 44100; const startSample = ~~(offsetTime * sampleRate); @@ -89,6 +87,7 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi filter.sourcePosition = startSample; const filterStartPosition = filter.position; + const soundtouch = filter.pipe; function onaudioprocess({ type, @@ -103,14 +102,9 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi const l = outputs[0][0]; const r = outputs[0][1]; - // naively take first pitch value for this sample + // TODO(MULTIGRID): average tempo, pitch across buffer const pitch = parameters.pitch && parameters.pitch[0]; - const soundtouch = filter.pipe; - - // TODO(MULTIGRID): need to minimize dspBufLength. - // is it possible to align dspBufLength with automation clip TICKs? - const syncBpm = parameters.bpm && parameters.bpm[0]; - const tempo = (isValidNumber(syncBpm) && isValidNumber(audioBpm)) ? (syncBpm / audioBpm) : 1; + const tempo = parameters.tempo && parameters.tempo[0]; if (isValidNumber(pitch)) { soundtouch.pitchSemitones = pitch; @@ -173,14 +167,15 @@ export function createSoundtouchNode({ audioContext, filter, startTime, offsetTi numberOfInputs: 2, numberOfOutputs: 2, bufferLength: windowBufferSize, + dspBufLength: DSP_BUFFER_LENGTH, parameters: [ { name: 'pitch', defaultValue: defaultPitch, }, { - name: 'bpm', - defaultValue: 128, + name: 'tempo', + defaultValue: 1, }, { name: 'isPlaying', diff --git a/app/lib/web-audio/soundtouch-node.js b/app/lib/web-audio/soundtouch-node.js index d2f8814a..9ac6d3e7 100644 --- a/app/lib/web-audio/soundtouch-node.js +++ b/app/lib/web-audio/soundtouch-node.js @@ -21,7 +21,7 @@ export default Ember.ObjectProxy.extend( outputNode: null, // TODO(V2): transpose dynamic - start(startTime, offsetTime, endTime, audioBpm, transpose) { + 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, - audioBpm, defaultPitch: transpose, }); this.set('node', node); diff --git a/app/mixins/playable-arrangement.js b/app/mixins/playable-arrangement.js index ae8b9991..df92b8d9 100644 --- a/app/mixins/playable-arrangement.js +++ b/app/mixins/playable-arrangement.js @@ -1,5 +1,7 @@ import Ember from 'ember'; +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'; @@ -14,7 +16,7 @@ export default Ember.Mixin.create({ // required params clips: null, - bpmScale: null, // or beatGrid + bpmControlPoints: null, audioContext: null, // optional params @@ -57,6 +59,22 @@ export default Ember.Mixin.create({ '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: Ember.computed('bpmScale', 'beatCount', 'timeSignature', function() { return BeatGrid.create(this.getProperties('bpmScale', 'beatCount', 'timeSignature')); }), diff --git a/app/mixins/playable-arrangement/automatable-clip/control.js b/app/mixins/playable-arrangement/automatable-clip/control.js index 25853065..2f870314 100644 --- a/app/mixins/playable-arrangement/automatable-clip/control.js +++ b/app/mixins/playable-arrangement/automatable-clip/control.js @@ -7,7 +7,8 @@ import RequireAttributes from 'linx/lib/require-attributes'; import { isValidNumber } from 'linx/lib/utils'; export const CONTROL_TYPE_VOLUME = 'gain'; -export const CONTROL_TYPE_BPM = 'bpm'; +export const CONTROL_TYPE_BPM = 'bpm'; // currently only exists in transition +export const CONTROL_TYPE_TEMPO = 'tempo'; export const CONTROL_TYPE_PITCH = 'pitch'; export const CONTROL_TYPE_DELAY_WET = 'delay-wet'; export const CONTROL_TYPE_DELAY_CUTOFF = 'delay-cutoff'; @@ -17,6 +18,7 @@ export const CONTROL_TYPE_FILTER_LOWPASS_CUTOFF = 'filter-lowpass-cutoff'; export const CONTROL_TYPES = [ CONTROL_TYPE_VOLUME, CONTROL_TYPE_BPM, + CONTROL_TYPE_TEMPO, CONTROL_TYPE_PITCH, CONTROL_TYPE_DELAY_WET, CONTROL_TYPE_DELAY_CUTOFF, diff --git a/app/mixins/playable-arrangement/track-clip.js b/app/mixins/playable-arrangement/track-clip.js index a94dd6d3..34609c68 100644 --- a/app/mixins/playable-arrangement/track-clip.js +++ b/app/mixins/playable-arrangement/track-clip.js @@ -24,7 +24,7 @@ import { import { default as AutomatableClipControlMixin, CONTROL_TYPE_VOLUME, - CONTROL_TYPE_BPM, + CONTROL_TYPE_TEMPO, CONTROL_TYPE_PITCH, CONTROL_TYPE_DELAY_WET, CONTROL_TYPE_DELAY_CUTOFF, @@ -43,11 +43,11 @@ const TrackVolumeControl = Ember.Object.extend( defaultValue: 1, }); -const TrackBpmControl = Ember.Object.extend( - AutomatableClipControlMixin('soundtouchNode.bpm'), { +const TrackTempoControl = Ember.Object.extend( + AutomatableClipControlMixin('soundtouchNode.tempo'), { - type: CONTROL_TYPE_BPM, - defaultValue: 128, // TODO(TECHDEBT): bpm default constant used in many places + type: CONTROL_TYPE_TEMPO, + defaultValue: 1, }); const TrackPitchControl = Ember.Object.extend( @@ -110,7 +110,7 @@ export default Ember.Mixin.create( controls: Ember.computed(function() { return [ TrackVolumeControl.create({ clip: this }), - TrackBpmControl.create({ clip: this }), + TrackTempoControl.create({ clip: this }), TrackPitchControl.create({ clip: this }), TrackDelayWetControl.create({ clip: this }), TrackDelayCutoffControl.create({ clip: this }), @@ -140,7 +140,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(); @@ -163,14 +162,13 @@ export default Ember.Mixin.create( return audioBeatGrid.beatToTime(audioStartBeat + clipBeat); }, - // TODO(TRACKMULTIGRID): audioBpm not constant - audioScheduleDidChange: Ember.observer('audioBinary.isReady', 'audioStartBeat', 'audioBeatCount', 'audioBpm', 'transpose', 'gain', function() { + audioScheduleDidChange: Ember.observer('audioBinary.isReady', 'audioStartBeat', 'audioBeatCount', 'transpose', 'gain', function() { Ember.run.once(this, 'startSource'); }).on('schedule'), startSource() { if (this.get('isScheduled') && this.get('audioBinary.isReady')) { - const { audioBpm, transpose } = this.getProperties('audioBpm', '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(); @@ -184,7 +182,7 @@ export default Ember.Mixin.create( Ember.Logger.log('startTrack', this.get('track.title'), startTime, offsetTime, endTime, transpose); const node = this.get('soundtouchNode'); - node && node.start(startTime, offsetTime, endTime, audioBpm, transpose); + node && node.start(startTime, offsetTime, endTime, transpose); } else { this.stopSource(); } diff --git a/app/models/mix.js b/app/models/mix.js index b4cfeeb7..56973ab8 100644 --- a/app/models/mix.js +++ b/app/models/mix.js @@ -13,7 +13,7 @@ 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', @@ -28,33 +28,7 @@ export default DS.Model.extend( // implement playable-arrangement session: Ember.inject.service(), audioContext: Ember.computed.reads('session.audioContext'), - 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); - }), - - 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); - }), - _bpmControlPoints: Ember.computed('bpm', + bpmControlPoints: Ember.computed('bpm', '_transitionBpmControlPoints.@each.{beat,value,transitionStartBeat}', function() { const bpmControlPoints = this.get('_transitionBpmControlPoints'); @@ -84,6 +58,17 @@ export default DS.Model.extend( } }), + 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'); diff --git a/app/models/mix/transition.js b/app/models/mix/transition.js index 92caa117..237c58ad 100644 --- a/app/models/mix/transition.js +++ b/app/models/mix/transition.js @@ -6,6 +6,7 @@ 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, @@ -19,10 +20,12 @@ import { isValidNumber } from 'linx/lib/utils'; export default DS.Model.extend( PlayableArrangementMixin, - DependentRelationshipMixin('fromTrackAutomationClips'), - DependentRelationshipMixin('toTrackAutomationClips'), - DependentRelationshipMixin('bpmClip'), - 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'), @@ -33,21 +36,37 @@ export default DS.Model.extend( 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', '_bpmClips'), - beatGrid: Ember.computed.reads('transitionClip.mix.beatGrid'), + clips: Ember.computed.uniq('fromTrackAutomationClips', 'toTrackAutomationClips', '_tempoClips'), + bpmControlPoints: Ember.computed.reads('bpmClip.controlPoints'), - _bpmClips: Ember.computed('bpmClip', function() { - return [this.get('bpmClip')].filter((clip) => !!clip); + _tempoClips: Ember.computed('bpmClip', 'fromTrackTempoClip', 'toTrackTempoClip', function() { + return [ + this.get('bpmClip'), + this.get('fromTrackTempoClip'), + this.get('toTrackTempoClip'), + ].filter((clip) => !!clip); }), - bpmControlPoints: Ember.computed.reads('bpmClip.controlPoints'), - // optimizes this transition, with given constraints // TODO(REFACTOR2): rethink this. convert to ember-concurrency optimize({ @@ -211,7 +230,7 @@ export default DS.Model.extend( // 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') + let toTrackBpm = this.get('fromTrackClip.track.audioMeta.bpm'); toTrackBpm = isValidNumber(toTrackBpm) ? toTrackBpm : fromTrackBpm; bpmClip.addControlPoints([ 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/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, +});