import Ember from 'ember';
import layout from './template';
import ExpFrameBaseComponent from '../exp-frame-base/component';
import FullScreen from '../../mixins/full-screen';
import VideoRecord from '../../mixins/video-record';
import ExpandAssets from '../../mixins/expand-assets';
import { audioAssetOptions, videoAssetOptions, imageAssetOptions } from '../../mixins/expand-assets';
import isColor from '../../utils/is-color';
import { observer } from '@ember/object';
let {
$
} = Ember;
// http://stackoverflow.com/a/12646864
function shuffleArrayInPlace(array) {
for (var i = array.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = array[i];
array[i] = array[j];
array[j] = temp;
}
return array;
}
/**
* @module exp-player
* @submodule frames
*/
/**
*
* Frame for a preferential looking "alternation" or "change detection" paradigm trial,
* in which separate streams of images are displayed on the left and right of the screen.
* Typically, on one side images would be alternating between two categories - e.g., images
* of 8 vs. 16 dots, images of cats vs. dogs - and on the other side the images would all
* be in the same category.
*
*
* The frame starts with an optional brief "announcement" segment, where an attention-getter
* video is displayed and audio is played. During this segment, the trial can be paused
* and restarted.
*
*
* If `doRecording` is true (default), then we wait for recording to begin before the
* actual test trial can begin. We also always wait for all images to pre-load, so that
* there are no delays in loading images that affect the timing of presentation.
*
*
* You can customize the appearance of the frame: background color overall, color of the
* two rectangles that contain the image streams, and border of those rectangles. You can
* also specify how long to present the images for, how long to clear the screen in between
* image pairs, and how long the test trial should be altogether.
*
*
* You provide four lists of images to use in this frame: `leftImagesA`, `leftImagesB`,
* `rightImagesA`, and `rightImagesB`. The left stream will alternate between images in
* `leftImagesA` and `leftImagesB`. The right stream will alternate between images in
* `rightImagesA` and `rightImagesB`. They are either presented in random order (default)
* within those lists, or can be presented in the exact order listed by setting
* `randomizeImageOrder` to false.
*
*
* The timing of all image presentations and the specific images presented is recorded in
* the event data.
*
*
* This frame is displayed fullscreen; if the frame before it is not, that frame
* needs to include a manual "next" button so that there's a user interaction
* event to trigger fullscreen mode. (Browsers don't allow switching to fullscreen
* without a user event.) If the user leaves fullscreen, that event is recorded, but the
* trial is not paused.
*
*
* Specifying media locations:
*
*
* For any parameters that expect a list of audio/video sources, you can EITHER provide
* a list of src/type pairs with full paths like this:
```json
[
{
'src': 'http://.../video1.mp4',
'type': 'video/mp4'
},
{
'src': 'http://.../video1.webm',
'type': 'video/webm'
}
]
```
* OR you can provide a single string 'stub', which will be expanded
* based on the parameter baseDir and the media types expected - either audioTypes or
* videoTypes as appropriate. For example, if you provide the audio source `intro`
* and baseDir is https://mystimuli.org/mystudy/, with audioTypes ['mp3', 'ogg'], then this
* will be expanded to:
```json
[
{
src: 'https://mystimuli.org/mystudy/mp3/intro.mp3',
type: 'audio/mp3'
},
{
src: 'https://mystimuli.org/mystudy/ogg/intro.ogg',
type: 'audio/ogg'
}
]
```
* This allows you to simplify your JSON document a bit and also easily switch to a
* new version of your stimuli without changing every URL. You can mix source objects with
* full URLs and those using stubs within the same directory. However, any stimuli
* specified using stubs MUST be
* organized as expected under baseDir/MEDIATYPE/filename.MEDIATYPE.
*
*
* Example usage:
```json
"frames": {
"alt-trial": {
"kind": "exp-lookit-change-detection",
"baseDir": "https://www.mit.edu/~kimscott/placeholderstimuli/",
"videoTypes": ["mp4", "webm"],
"audioTypes": ["mp3", "ogg"],
"trialLength": 15,
"attnLength": 2,
"fsAudio": "sample_1",
"unpauseAudio": "return_after_pause",
"pauseAudio": "pause",
"videoSources": "attentiongrabber",
"musicSources": "music_01",
"audioSources": "video_01",
"endAudioSources": "all_done",
"border": "thick solid black",
"leftImagesA": ["apple.jpg", "orange.jpg"],
"rightImagesA": ["square.png", "tall.png", "wide.png"],
"leftImagesB": ["apple.jpg", "orange.jpg"],
"rightImagesB": ["apple.jpg", "orange.jpg"],
"startWithA": true,
"randomizeImageOrder": true,
"displayMs": 500,
"blankMs": 250,
"containerColor": "white",
"backgroundColor": "#abc",
}
}
* ```
* @class Exp-lookit-change-detection
* @extends Exp-frame-base
* @uses Full-screen
* @uses Video-record
* @uses Expand-assets
*/
export default ExpFrameBaseComponent.extend(FullScreen, VideoRecord, ExpandAssets, {
type: 'exp-lookit-geometry-alternation',
layout: layout,
displayFullscreen: true, // force fullscreen for all uses of this component
fullScreenElementId: 'experiment-player',
fsButtonID: 'fsButton',
// Track state of experiment
completedAudio: false,
completedAttn: false,
currentSegment: 'intro', // 'test' (mutually exclusive)
alreadyStartedCalibration: false,
// Override setting in VideoRecord mixin - only use camera if doing recording
doUseCamera: Ember.computed.alias('doRecording'),
startRecordingAutomatically: Ember.computed.alias('doRecording'),
recordingStarted: false,
imageIndexA: 0,
imageIndexB: 0,
doingA: false,
musicFadeLength: 2000,
assetsToExpand: {
'audio': [
'audioSources',
'musicSources',
'endAudioSources',
'pauseAudio',
'unpauseAudio',
'fsAudio'
],
'video': [
'videoSources'
],
'image': [
'leftImagesA',
'rightImagesA',
'leftImagesB',
'rightImagesB'
]
},
readyToStartCalibration: Ember.computed('recordingStarted', 'completedAudio', 'completedAttn', 'image_loaded_count',
function() {
var recordingStarted = false;
if (this.get('session').get('recorder')) {
recordingStarted = this.get('session').get('recorder').get('recording');
} else {
recordingStarted = this.get('recordingStarted');
}
var nImages = this.get('leftImagesA_parsed').length + this.get('leftImagesB_parsed').length +
this.get('rightImagesA_parsed').length + this.get('rightImagesB_parsed').length;
return ((recordingStarted || !this.get('doRecording')) && this.get('completedAudio') && this.get('completedAttn') && this.get('image_loaded_count') >= nImages);
}),
doingIntro: Ember.computed('currentSegment', function() {
return (this.get('currentSegment') === 'intro');
}),
isPaused: false,
hasBeenPaused: false,
// Timers for intro & stimuli
introTimer: null, // minimum length of intro segment
stimTimer: null,
frameSchemaProperties: {
/**
* Whether to do webcam recording on this frame
*
* @property {Boolean} doRecording
*/
doRecording: {
type: 'boolean',
description: 'Whether to do webcam recording',
default: true
},
/**
* minimum amount of time to show attention-getter in seconds. If 0, attention-getter
* segment is skipped.
*
* @property {Number} attnLength
* @default 0
*/
attnLength: {
type: 'number',
description: 'minimum amount of time to show attention-getter in seconds',
default: 0
},
/**
* length of alternation trial in seconds. This refers only to the section of the
* trial where the alternating image streams are presented - it does not count
* any announcement phase.
*
* @property {Number} trialLength
* @default 60
*/
trialLength: {
type: 'number',
description: 'length of alternation trial in seconds',
default: 60
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* instructions during attention-getter video
*
* @property {Object[]} audioSources
*/
audioSources: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for instructions during attention-getter video',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* music during trial
*
* @property {Object[]} musicSources
*/
musicSources: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for music during trial',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio after completion of trial (optional; used for last
* trial "okay to open your eyes now" announcement)
*
* @property {Object[]} endAudioSources
*/
endAudioSources: {
oneOf: audioAssetOptions,
description: 'Supply this to play audio at the end of the trial; list of objects specifying audio src and type',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* attention-getter video (should be loopable)
*
* @property {Object[]} videoSources
*/
videoSources: {
oneOf: videoAssetOptions,
description: 'List of objects specifying video src and type for attention-getter video',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio played upon pausing study
*
* @property {Object[]} pauseAudio
*/
pauseAudio: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for audio played when pausing study',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio played upon unpausing study
*
* @property {Object[]} unpauseAudio
*/
unpauseAudio: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for audio played when pausing study',
default: []
},
/**
* Sources Array of {src: 'url', type: 'MIMEtype'} objects for
* audio played when study is paused due to not being fullscreen
*
* @property {Object[]} fsAudio
*/
fsAudio: {
oneOf: audioAssetOptions,
description: 'List of objects specifying audio src and type for audio played when pausing study if study is not fullscreen',
default: []
},
/**
* Whether to start with the 'A' image list on both left and right. If true, both
* sides start with their respective A image lists; if false, both lists start with
* their respective B image lists.
*
* @property {Boolean} startWithA
* @default true
*/
startWithA: {
type: 'boolean',
description: 'Whether to start with image list A',
default: true
},
/**
* Whether to randomize image presentation order within the lists leftImagesA,
* leftImagesB, rightImagesA, and rightImagesB. If true (default), the order
* of presentation is randomized. Each time all the images in one list have been
* presented, the order is randomized again for the next 'round.' If false, the
* order of presentation is as written in the list. Once all images are presented,
* we loop back around to the first image and start again.
*
* Example of randomization: suppose we have defined
* ```
* leftImagesA: ['apple', 'banana', 'cucumber'],
* leftImagesB: ['aardvark', 'bat'],
* randomizeImageOrder: true,
* startWithA: true
* ```
*
* And suppose the timing is such that we end up with 10 images total. Here is a
* possible sequence of images shown on the left:
*
* ['banana', 'aardvark', 'apple', 'bat', 'cucumber', 'bat', 'cucumber', 'aardvark', 'apple', 'bat']
*
* @property {Boolean} randomizeImageOrder
* @default true
*/
randomizeImageOrder: {
type: 'boolean',
description: 'Whether to randomize image presentation order within lists',
default: true
},
/**
* Amount of time to display each image, in milliseconds
*
* @property {Number} displayMs
* @default 750
*/
displayMs: {
type: 'number',
description: 'Amount of time to display each image, in milliseconds',
default: 500
},
/**
* Amount of time for blank display between each image, in milliseconds
*
* @property {Number} blankMs
* @default 750
*/
blankMs: {
type: 'number',
description: 'Amount of time for blank display between each image, in milliseconds',
default: 250
},
/**
* Format of border to display around alternation streams, if any. See
* https://developer.mozilla.org/en-US/docs/Web/CSS/border for syntax.
*
* @property {String} border
* @default 'thin solid gray'
*/
border: {
type: 'string',
description: 'Amount of time for blank display between each image, in milliseconds',
default: 'thin solid gray'
},
/**
* Color of background. See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
* for acceptable syntax: can use color names ('blue', 'red', 'green', etc.), or
* rgb hex values (e.g. '#800080' - include the '#')
*
* @property {String} backgroundColor
* @default 'white'
*/
backgroundColor: {
type: 'string',
description: 'Color of background',
default: 'white'
},
/**
* Color of image stream container, if different from overall background.
* Defaults to backgroundColor if one is provided.
* https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
* for acceptable syntax: can use color names ('blue', 'red', 'green', etc.), or
* rgb hex values (e.g. '#800080' - include the '#')
*
* @property {String} containerColor
* @default 'white'
*/
containerColor: {
type: 'string',
description: 'Color of image stream container',
default: 'white'
},
/**
* Set A of images to display on left of screen. Left stream will alternate between
* images from set A and from set B. Elements of list can be full URLs or relative
* paths starting from `baseDir`.
*
* @property {String[]} leftImagesA
*/
leftImagesA: {
type: 'array',
description: 'Set A of images to display on left of screen',
default: [],
items: {
oneOf: imageAssetOptions
}
},
/**
* Set B of images to display on left of screen. Left stream will alternate between
* images from set A and from set B. Elements of list can be full URLs or relative
* paths starting from `baseDir`.
*
* @property {String[]} leftImagesB
*/
leftImagesB: {
type: 'array',
description: 'Set B of images to display on left of screen',
default: [],
items: {
oneOf: imageAssetOptions
}
},
/**
* Set A of images to display on right of screen. Right stream will alternate between
* images from set A and from set B. Elements of list can be full URLs or relative
* paths starting from `baseDir`.
*
* @property {String[]} rightImagesA
*/
rightImagesA: {
type: 'array',
description: 'Set A of images to display on right of screen',
default: [],
items: {
oneOf: imageAssetOptions
}
},
/**
* Set B of images to display on right of screen. Right stream will alternate between
* images from set A and from set B. Elements of list can be full URLs or relative
* paths starting from `baseDir`.
*
* @property {String[]} rightImagesA
*/
rightImagesB: {
type: 'array',
description: 'Set B of images to display on right of screen',
default: [],
items: {
oneOf: imageAssetOptions
}
}
},
meta: {
data: {
type: 'object',
properties: {
/**
* Sequence of images shown on the left
* @attribute leftSequence
*/
leftSequence: {
type: 'Object'
},
/**
* Sequence of images shown on the right
* @attribute rightSequence
*/
rightSequence: {
type: 'Object'
},
videoId: {
type: 'string'
},
/**
* Whether this trial was paused
* @attribute hasBeenPaused
*/
hasBeenPaused: {
type: 'boolean'
}
}
}
},
calObserver: observer('readyToStartCalibration', function(frame) {
if (frame.get('readyToStartCalibration') && frame.get('currentSegment') === 'intro') {
if (!frame.checkFullscreen()) {
frame.pauseStudy();
} else {
frame.set('currentSegment', 'test');
}
}
}),
segmentObserver: observer('currentSegment', function(frame) {
// Don't trigger starting intro; that'll be done manually.
if (frame.get('currentSegment') === 'test') {
frame.startTrial();
}
}),
didRender() {
this._super(...arguments);
if (this.get('doingCalibration') && !this.get('alreadyStartedCalibration')) {
this.set('alreadyStartedCalibration', true);
this.startCalibration();
}
},
actions: {
// When intro audio is complete
endAudio() {
this.set('completedAudio', true);
this.notifyPropertyChange('readyToStartCalibration');
},
finish() {
// Call this something separate from next because stopRecorder promise needs
// to call next AFTER recording is stopped and we don't want this to have
// already been destroyed at that point.
/**
* Just before stopping webcam video capture
*
* @event stoppingCapture
*/
var _this = this;
this.stopRecorder().then(() => {
_this.set('stoppedRecording', true);
_this.send('next');
return;
}, () => {
_this.send('next');
return;
});
this._super(...arguments);
}
},
startIntro() {
// Allow pausing during intro
var _this = this;
$(document).off('keyup.pauser');
$(document).on('keyup.pauser', function(e) {_this.handleSpace(e, _this);});
// Start placeholder video right away
/**
* Immediately before starting intro/announcement segment
*
* @event startIntro
*/
this.send('setTimeEvent', 'startIntro');
if (this.get('attnLength')) {
$('#player-video')[0].play();
// Set a timer for the minimum length for the intro/break
$('#player-audio')[0].play();
this.set('introTimer', window.setTimeout(function() {
_this.set('completedAttn', true);
_this.notifyPropertyChange('readyToStartCalibration');
}, _this.get('attnLength') * 1000));
} else {
_this.set('completedAttn', true);
_this.set('completedAudio', true);
_this.notifyPropertyChange('readyToStartCalibration');
}
},
startTrial() {
var _this = this;
/**
* Immediately before starting test trial segment
*
* @event startTestTrial
*/
_this.send('setTimeEvent', 'startTestTrial');
// Begin playing music; fade in and set to fade out at end of trial
var $musicPlayer = $('#player-music');
$musicPlayer.prop('volume', 0.1);
$musicPlayer[0].play();
$musicPlayer.animate({volume: 1}, _this.get('musicFadeLength'));
window.setTimeout(function() {
$musicPlayer.animate({volume: 0}, _this.get('musicFadeLength'));
}, _this.get('trialLength') * 1000 - _this.get('musicFadeLength'));
// Start presenting triangles and set to stop after trial length
$('#allstimuli').show();
_this.presentImages();
window.setTimeout(function() {
window.clearTimeout(_this.get('stimTimer'));
_this.clearImages();
_this.endTrial();
}, _this.get('trialLength') * 1000);
},
// When triangles have been shown for time indicated: play end-audio if
// present, or just move on.
endTrial() {
this.stopRecorder();
if (this.get('endAudioSources').length) {
$('#player-endaudio')[0].play();
} else {
this.send('finish');
}
},
clearImages() {
/**
* Records each time images are cleared from display
*
* @event clearImages
*/
this.send('setTimeEvent', 'clearImages');
$('.stream-container').html('');
},
presentImages() {
var A = this.get('doingA');
var leftImageList = A ? this.get('leftImagesA_parsed') : this.get('leftImagesB_parsed');
var rightImageList = A ? this.get('rightImagesA_parsed') : this.get('rightImagesB_parsed');
var imageIndex = A ? this.get('imageIndexA') : this.get('imageIndexB');
var leftImageIndex = imageIndex % leftImageList.length;
var rightImageIndex = imageIndex % rightImageList.length;
if (leftImageIndex == 0 && this.get('randomizeImageOrder')) {
shuffleArrayInPlace(leftImageList);
}
if (rightImageIndex == 0 && this.get('randomizeImageOrder')) {
shuffleArrayInPlace(rightImageList);
}
if (A) {
this.set('imageIndexA', this.get('imageIndexA') + 1);
} else {
this.set('imageIndexB', this.get('imageIndexB') + 1);
}
this.set('doingA', !this.get('doingA'));
var _this = this;
_this.clearImages();
_this.set('stimTimer', window.setTimeout(function() {
$('#left-stream-container').html(`<img src=${leftImageList[leftImageIndex]} class="stim-image" alt="left image">`);
$('#right-stream-container').html(`<img src=${rightImageList[rightImageIndex]} class="stim-image" alt="right image">`);
/**
* Immediately after making images visible
*
* @event presentImages
* @param {String} left url of left image
* @param {String} right url of right image
*/
_this.send('setTimeEvent', 'presentImages', {
left: leftImageList[leftImageIndex],
right: rightImageList[rightImageIndex]
});
_this.set('stimTimer', window.setTimeout(function() {
_this.presentImages();
}, _this.get('displayMs')));
}, _this.get('blankMs')));
},
handleSpace(event, frame) {
if (frame.checkFullscreen() || !frame.isPaused) {
if (event.which === 32) { // space
frame.pauseStudy();
}
}
},
// Pause/unpause study; only called if doing intro.
pauseStudy() {
$('#player-audio')[0].pause();
$('#player-audio')[0].currentTime = 0;
$('#player-pause-audio')[0].pause();
$('#player-pause-audio')[0].currentTime = 0;
$('#player-pause-audio-leftfs')[0].pause();
$('#player-pause-audio-leftfs')[0].currentTime = 0;
this.set('completedAudio', false);
this.set('completedAttn', false);
Ember.run.once(this, () => {
this.set('hasBeenPaused', true);
var wasPaused = this.get('isPaused');
this.set('currentSegment', 'intro');
// Currently paused: RESUME
if (wasPaused) {
this.startIntro();
this.set('isPaused', false);
} else { // Not currently paused: PAUSE
window.clearTimeout(this.get('introTimer'));
if (this.checkFullscreen()) {
$('#player-pause-audio')[0].play();
} else {
$('#player-pause-audio-leftfs')[0].play();
}
this.set('isPaused', true);
}
});
},
image_loaded_count: 0,
didInsertElement() {
this._super(...arguments);
this.set('doingA', this.get('startWithA'));
this.notifyPropertyChange('readyToStartCalibration');
var _this = this;
$.each([this.get('leftImagesA_parsed'), this.get('leftImagesB_parsed'), this.get('rightImagesA_parsed'), this.get('rightImagesB_parsed')],
function(idx, imgList) {
$.each(imgList, function(idx, url) {
var img = new Image();
img.onload = function() { // set onload fn before source to ensure we catch it
_this.set('image_loaded_count', _this.get('image_loaded_count') + 1);
_this.notifyPropertyChange('readyToStartCalibration');
};
img.onerror = function() {
_this.set('image_loaded_count', _this.get('image_loaded_count') + 1);
_this.notifyPropertyChange('readyToStartCalibration');
console.error('Unable to load image at ', url, ' - will skip loading but this may cause the exp-lookit-change-detection frame to fail');
};
img.src = url;
});
});
if (this.get('border').includes(';')) {
console.warn('Invalid border css provided to exp-lookit-change-detection; not applying.');
} else {
$('#allstimuli div.stream-container').css('border', this.get('border'));
}
if (isColor(this.get('backgroundColor'))) {
$('div.exp-lookit-change-detection').css('background-color', this.get('backgroundColor'));
} else {
console.warn('Invalid background color provided to exp-lookit-change-detection; not applying.');
}
if (isColor(this.get('containerColor'))) {
$('div.exp-lookit-change-detection div.stream-container').css('background-color', this.get('containerColor'));
} else {
console.warn('Invalid container color provided to exp-lookit-change-detection; not applying.');
}
$('#allstimuli').hide();
this.startIntro();
},
willDestroyElement() { // remove event handler
$(document).off('keyup.pauser');
window.clearInterval(this.get('introTimer'));
window.clearInterval(this.get('stimTimer'));
this._super(...arguments);
},
/**
* What to do when individual-frame recording starts.
* @method onRecordingStarted
* @private
*/
onRecordingStarted() {
this.set('recordingStarted', true);
this.notifyPropertyChange('readyToStartCalibration');
},
/**
* What to do when session-level recording starts.
* @method onSessionRecordingStarted
* @private
*/
onSessionRecordingStarted() {
this.set('recordingStarted', true);
this.notifyPropertyChange('readyToStartCalibration');
}
});