all files / src/ Player.js

97.37% Statements 74/76
92.59% Branches 25/27
100% Functions 9/9
97.3% Lines 72/74
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181                      253× 253× 253× 253× 119×   134×     253×   253×   253× 253× 253×   253× 253×             301×   203× 203×   70× 70×   14× 14×         14×         28× 28× 28× 14×   28× 14×         28× 28× 28×   28× 14×                     203× 203×   203× 203× 203×   203×               217× 217× 5214×                 5459×   5459×   5368× 2820×   5368× 5464×         91× 91× 91×       3445× 91×       5459× 5459× 5459×                   70× 70×         257× 257× 257× 257×   257×     257× 72× 72× 72× 72×     185× 185× 185×         257×              
/**
 * Orchestrates the audible (and visual cursor) rendering of the chart
 * @private
 * @param {integer} duration - the length of the rendering in milliseconds
 * @param {DataWrapper} data - the underlying data (wrapped in interface)
 * @param {PitchMapper} pitchMapper - maps data to pitches
 * @param {Sounder} sounder - the sounder object
 * @param {VisualCallback} visualCallback - the callback function that highlights the current datum
 */
class Player {
	constructor(duration, data, pitchMapper, sounder, visualCallback) {
		this.data = data
		this.pitchMapper = pitchMapper
		this.sounder = sounder
		if (arguments.length < 5) {
			this.visualCallback = null
		} else {
			this.visualCallback = visualCallback
		}
 
		const seriesLen = this.data.seriesLength(0)
 
		this._numberOfSeries = this.data.numSeries()
 
		const sampling = Player._samplingInfo(duration, seriesLen)
		this.interval = sampling.interval
		this.sampleOneIn = sampling.in
 
		this.seriesMaxIndex = seriesLen - 1  // TODO just use seriesLen?
		this._state = 'ready'
	}
 
	/**
	 * Main entry point; manages state.
	 */
	playPause() {
		switch (this._state) {
			case 'ready':
				this._play()
				break
			case 'playing':
				this._pause()
				break
			case 'paused':
				this._playLoop()
				break
			case 'finished':
				this._play()
				break
			default:
				throw Error('Player error: invalid state: ' + String(this._state))
		}
	}
 
	stepBackward(skip) {
		const delta = skip || 50
		this.playIndex -= delta
		if (this.playIndex < 0) {
			this.playIndex = 0  // TODO test limiting
		}
		if (this._state === 'paused') {
			this._playOne()
		}
	}
 
	stepForward(skip) {
		const delta = skip || 50
		this.playIndex += delta
		if (this.playIndex > this.seriesMaxIndex) {
			this.playIndex = this.seriesMaxIndex  // TODO test limiting
		}
		if (this._state === 'paused') {
			this._playOne()
		}
	}
 
	/**
	 * Resets play state and sets up a recurring function to update the sound
	 * (and, optionally, visual callback) at an interval dependant on the
	 * number of data.
	 */
	_play() {
		// Debugging info
		this.playTimes = []  // store all lengths of time that playOne took
		this.playCount = 0   // how many datum points were actually sounded?
 
		this.startTime = performance.now()
		this.sounder.start(0)
		this.playIndex = 0
 
		this._playLoop()
	}
 
	/**
	 * Update state and set _playOne() to run regularly, to render the sound
	 * (and optional visual cursor movement).
	 */
	_playLoop() {
		this._state = 'playing'
		this._playOne()  // so that it starts immediately
		this.intervalID = setInterval(() => this._playOne(), this.interval)
	}
 
	/**
	 * This is where the sound is actually played.  If a visual callback was
	 * specified, this also coordinates the visual highlighting of the current
	 * datum as the playback occurs.
	 */
	_playOne() {
		const thisPlayTimeStart = performance.now()
 
		if (this.playIndex <= this.seriesMaxIndex) {
			// Play back one datum point
			if (this.visualCallback !== null) {
				this.visualCallback(this.playIndex)
			}
			for (let i = 0; i < this._numberOfSeries; i++ ) {
				this.sounder.frequency(i, this.pitchMapper.map(i,
					this.data.seriesValue(i, this.playIndex)))
			}
		} else {
			// Playback is complete; clean up
			clearInterval(this.intervalID)
			this.sounder.stop()
			this._state = 'finished'
 
			// Debugging info
			// console.log(`Player: Playing ${this.playCount} of ${this.playIndex} took ${Math.round(performance.now() - this.startTime)} ms`)
			const sum = this.playTimes.reduce((acc, cur) => acc + cur)
			const mean = sum / this.playTimes.length
			// console.log(`Player: Average play func time: ${mean.toFixed(2)} ms`)
		}
 
		this.playIndex += this.sampleOneIn > 0 ? this.sampleOneIn : 1  // TODO sl
		this.playCount += 1
		this.playTimes.push(performance.now() - thisPlayTimeStart)
	}
 
	/**
	 * Temporarily pause the rendering of the chart.
	 * This inherently keeps the sound going at the frequency it was at when
	 * the pause was triggered.
	 * @todo feature/object to stop/fade the sound after n seconds?
	 */
	_pause() {
		clearInterval(this.intervalID)
		this._state = 'paused'
	}
 
	/* Work out sampling rate */
	static _samplingInfo(duration, seriesLen) {
		const minInterval = 10
		let interval
		let sampleOneIn
		let slots
 
		const idealInterval = Math.ceil(duration / seriesLen)
		// console.log(`sampleInfo: duration: ${duration}; series length: ${seriesLen}; ideal interval: ${idealInterval}`)
 
		if (idealInterval < minInterval) {
			interval = minInterval
			slots = Math.floor(duration / minInterval)
			const sampleOneInFloat = seriesLen / slots
			sampleOneIn = Math.round(seriesLen / slots)
			// console.log(`sampleInfo: Need to sample 1 in ${sampleOneIn} (${sampleOneInFloat})`)
		} else {
			slots = Math.floor(duration / minInterval)
			interval = idealInterval
			sampleOneIn = 1
		}
 
		// console.log(`sampleInfo: it will take ${ (seriesLen / sampleOneIn) * interval}`)
 
		return {
			'sample': 1,
			'in': sampleOneIn,
			'interval': interval
		}
	}
}