Files
jspsych-plugins/jspsych-nafc-adaptive.js
2026-04-22 10:43:26 +02:00

398 lines
16 KiB
JavaScript
Executable File

/*------------------------------------------------------------------------------
* n-AFC Adaptive Task generic for jsPsych
*------------------------------------------------------------------------------
* This template requires the jspsych-audio-sequence-button-response plugin.
*------------------------------------------------------------------------------
* Author: Etienne Gaudrain <etienne.gaudrain@cnrs.fr>
* 2020-05-19: Fixed some typos
* 2020-12-08: Fixed threshold calculation
*----------------------------------------------------------------------------*/
/* To use, you need to include the following in your webpage:
<script src="/js/vendor/jspsych.js"></script>
<script src="/js/vendor/jspsych-plugins/jspsych-instructions.js"></script>
<script src="/js/vendor/jspsych-plugins/jspsych-waitfor-function.js"></script>
<script src="/js/vendor/jspsych-plugins/jspsych-audio-sequence-button-response.js"></script>
*/
/* Here's an example for next_trial:
function prepare_trial(last_trial, step, options, condition, done){
var diff = last_trial.trial_definition.f0 + step;
var new_trial = {
stimuli: null,
i_correct: getRandomIntInclusive(0,3),
trial_definition: {f0: diff+"st"}, // The parameters that define the trial
step: step, // Just in case we modified the provided step
difference: diff
};
async_generate_stimuli(function(){
new_trial.stimuli = ['blah', 'blah', 'blih'];
done(new_trial);
});
}
function send_data_securely_to_server(data){
console.log(data.last().values().json());
}
*/
/* Here's an example for options:
var options = {
initial_step_size: 2,
starting_difference: 12,
step_size_modifier: 1/Math.sqrt(2),
down_up: [2, 1], // 2-down, 1-up => 70.7%
terminate_on_nturns: 8,
terminate_on_ntrials: 150,
terminate_on_max_difference: 25,
threshold_on_last_nturns: 6,
change_step_size_on_difference: 2,
change_step_size_on_ntrials: 15,
prompt: "Which of the three is different from the two others?",
isi: 250,
intervals: ['1', '2', '3'],
prepare_trial: prepare_trial,
after_the_run: send_data_securely_to_server, // function(options, condition, data, success_cb())
start_button: 'Beginnen',
opening_message: "<h1>Test</h1><p>You will hear three sounds. Choose the one that is different from the two others.</p>",
closing_message: "<h1>Thank you!</h1><p>This is the end of this block. Thank you for your help!</p>"
};
*/
// This relies on some tools and polyfills that are in /js/tools.js
function nAFC_adapt(opts, condition) {
// This generates trials for one "condition", that is a whole run that leads
// to a threshold.
//
// `options` defines the parameters of the AFC
// `condition` defines the condition along which the parameters are being adapted.
var options = jsPsych.utils.deepCopy(opts);
// Mandatory arguments in options
var mand_opts = ['initial_step_size', 'starting_difference', 'down_up', 'terminate_on_nturns',
'terminate_on_ntrials', 'terminate_on_max_difference', 'threshold_on_last_nturns', 'step_size_modifier', 'change_step_size_on_difference',
'change_step_size_on_ntrials', 'intervals', 'prepare_trial'
];
for(var k of mand_opts) {
if(!(k in options)) {
throw "nAFC_adapt: " + k + " is a mandatory option to provide.";
return null;
}
}
if(typeof options.opening_message === 'undefined')
options.opening_message = null;
if(typeof options.closing_message === 'undefined')
options.closing_message = null;
if(typeof options.prompt === 'undefined')
options.prompt = null;
if(typeof options.isi === 'undefined')
options.isi = 0;
if(typeof options.after_the_run === 'undefined')
options.after_the_run = function(dat, done){ done(); };
options.current_step = 0;
options.current_step_size = options.initial_step_size;
options.current_difference = options.starting_difference;
options.change_step_size_on_ntrials_counter = 0;
function is_correct(t) {
return parseInt(t.button_pressed) == t.i_correct;
}
function whereto_next(last_trials) {
// This returns the new step based on the last n trials.
if(typeof whereto_next.v === 'undefined')
whereto_next.v = [];
whereto_next.v.push(last_trials.slice(-1)[0].correct);
// We go neither up nor down
var step = 0;
console.log("---------------------- WHERE TO NEXT?");
console.log(whereto_next.v);
options.change_step_size_on_ntrials_counter++;
// Are we going down?
//if(typeof last_trials[last_trials.length - options.down_up[0]] !== 'undefined') {
if(whereto_next.v.length >= options.down_up[0] && whereto_next.v.slice(-options.down_up[0]).every(Boolean)) {
// We're goin' down!
if(options.current_difference - options.current_step_size <= 0)
options.current_step_size = options.current_difference / 2;
else if(options.current_difference <= options.current_step_size * options.change_step_size_on_difference ||
options.change_step_size_on_ntrials_counter == options.change_step_size_on_ntrials) {
options.current_step_size = options.current_step_size * options.step_size_modifier;
options.change_step_size_on_ntrials_counter = 0;
}
step = -options.current_step_size;
console.log("WE'RE GOIN' DOWN!");
whereto_next.v = [];
}
// Are we going up?
//if(typeof last_trials[last_trials.length - options.down_up[1]] !== 'undefined') {
else if(whereto_next.v.length >= options.down_up[1] && whereto_next.v.slice(-options.down_up[1]).every(function(t) { return !t; })) {
// We're goin' up!
if(options.current_difference <= options.current_step_size * options.change_step_size_on_difference ||
options.change_step_size_on_ntrials_counter == options.change_step_size_on_ntrials) {
options.current_step_size = options.current_step_size * options.step_size_modifier;
options.change_step_size_on_ntrials_counter = 0;
}
step = options.current_step_size;
console.log("WE'RE GOIN' UP!");
whereto_next.v = [];
}
else {
console.log("WE'RE GOIN' NOWHERE!");
}
if(options.change_step_size_on_ntrials_counter == options.change_step_size_on_ntrials) {
options.change_step_size_on_ntrials_counter = 0;
}
return step;
}
function do_we_keep_going(data) {
// Data is a jsPsych DataCollection that contains all the audio-sequence-button-response trials so far
// returns true to continue, and false to stop
// When we stop we insert a new row in the data collection containing the threshold or the reason why we stopped
var steps = data.select('step').values;
steps.push(options.current_step);
var differences = data.select('difference').values;
// The current difference hasn't been computed yet, this happens prepare_trial(), so we add the likely next value
differences.push(options.current_difference + options.current_step);
if(data.count() >= options.terminate_on_ntrials) {
jsPsych.setProgressBar(1);
var corrects = data.select('correct').values;
jsPsych.data.get().push({
type: 'threshold',
threshold: NaN,
geom_threshold: NaN,
reason: 'ntrials',
steps: steps,
differences: differences,
condition: condition,
corrects: corrects,
internal_node_id: data.last().select('internal_node_id').values[0]
});
return false;
} else if(differences.slice(-1)[0]>options.terminate_on_max_difference) {
jsPsych.setProgressBar(1);
var corrects = data.select('correct').values;
jsPsych.data.get().push({
type: 'threshold',
threshold: NaN,
geom_threshold: NaN,
reason: 'max_difference',
steps: steps,
differences: differences,
condition: condition,
corrects: corrects,
internal_node_id: data.last().select('internal_node_id').values[0]
});
return false;
} else {
var snnsd = steps.non_zero().map(Math.sign).diff().non_zero();
nturns = snnsd.length;
console.log("---------------------- DO WE KEEP GOIN'?");
console.log("Corrects: ["+data.select('correct').values+"]");
console.log("steps: ["+steps+"]");
console.log("steps!=0: ["+steps.non_zero()+"]");
console.log("sign(steps!=0): ["+steps.non_zero().map(Math.sign)+"]");
console.log("diff(sign(steps!=0)): ["+steps.non_zero().map(Math.sign).diff()+"]");
console.log("snnsd: ["+snnsd+"]");
console.log("nturns:"+nturns);
jsPsych.setProgressBar(nturns/options.terminate_on_nturns);
if(nturns >= options.terminate_on_nturns) {
// That's a nice way of ending... Let's calculate the threshold
var i_nz = steps.findIndices(function(x) { return x != 0; });
// EG: 2020-12-07, correcting stupid mistake
//var i_tp = i_nz.filter(function(x, i) { return snnsd[i] != 0; });
var i_d = steps.non_zero().map(Math.sign).diff().findIndices(function(x) { return x != 0; });
var i_tp = i_nz.select(i_d);
// -- end edit 2020-12-07
i_tp.push(differences.length - 1);
i_tp = i_tp.slice(-options.threshold_on_last_nturns);
var corrects = data.select('correct').values;
thr = differences.select(i_tp).mean();
geom_thr = Math.exp(differences.select(i_tp).map(Math.log).mean());
jsPsych.data.get().push({
type: 'threshold',
threshold: thr,
geom_threshold: geom_thr,
reason: 'nturns',
steps: steps,
differences: differences,
condition: condition,
corrects: corrects,
internal_node_id: data.last().select('internal_node_id').values[0]
});
return false;
}
}
return true;
}
// Make an adaptive run based on a condition
var run_timeline = [];
if(options.opening_message != null) {
run_timeline.push({
type: 'instructions',
pages: [
options.opening_message
],
show_clickable_nav: true,
button_label_next: options.start_button,
allow_backward: false,
on_start: function(){
jsPsych.setProgressBar(0);
}
});
}
//var ExpState = { N: 0 };
var t = {
timeline: [{
type: 'waitfor-function',
func: function(done) {
//var files = ['/audio/Beer.wav', '/audio/Beer.wav', '/audio/Beer.wav'];
//ExpState.files = files;
var last_trial = jsPsych.data.get().filter({trial_type: 'audio-sequence-button-response'}).last().values()[0];
// prepare_trial is a user provided function that expects the following arguments:
// last_trial: the last trial
// step: the new step that needs applying
// options: all the options
// condition: a definition of the condition we're in
// success_callback: the function that will be called upon success, which takes the new trial definition
// new_trial has the following keys:
// stimuli: the list of sound files to load
// i_correct: the index of the correct response
// trial_definition: the parameters that define the trial
// step: the step used to create the new trial
// difference: the difference used to create the new trial
options.prepare_trial(
last_trial, options.current_step, options, condition,
function(new_trial) {
options.current_difference = new_trial.difference;
jsPsych.pluginAPI.preloadAudioFiles(new_trial.stimuli, function() {
done(new_trial);
});
}
);
},
async: true,
min_duration: 1000
},
{
type: 'audio-sequence-button-response',
stimuli: function() {
return jsPsych.data.getLastTrialData().values()[0].value.stimuli;
},
data: function() {
return jsPsych.data.getLastTrialData().values()[0].value;
},
trial_ends_after_audio: false,
i_correct: function() {
return jsPsych.data.getLastTrialData().values()[0].value.i_correct;
},
visual_feedback: options.visual_feedback,
button_html: "<button class='jspsych-btn afc-btn'>%choice%</button>",
choices: options.intervals,
prompt: options.prompt,
prompt_position: 'top',
isi: options.isi,
on_finish: function(data) {
data.correct = is_correct(data);
console.log("Correct? "+data.correct);
}
}
],
loop_function: function(data) {
// If only we had access to the current nodeID, things would be a bit simpler... but we'll hack something
var last_trial_node_id = jsPsych.data.getLastTrialData().select('internal_node_id').values[0];
var the_node_id_we_want = last_trial_node_id.split('-').slice(0,-2).join("-");
options.current_step = whereto_next(jsPsych.data.get().filter({trial_type: 'audio-sequence-button-response'}).last(Math.max(options.down_up[0], options.down_up[1]) + 1).values());
return do_we_keep_going(jsPsych.data.getDataByTimelineNode(the_node_id_we_want).filter({ trial_type: 'audio-sequence-button-response' }));
},
};
run_timeline.push(t);
// To save the data... would be better suited for on_finish, but we need async
run_timeline.push({
type: 'waitfor-function',
func: function(done) {
var last_trial_node_id = jsPsych.data.getLastTrialData().select('internal_node_id').values[0];
var the_node_id_we_want = last_trial_node_id.split('-').slice(0,-2).join("-");
options.after_the_run(options, condition, jsPsych.data.getDataByTimelineNode(the_node_id_we_want), done);
},
async: true,
min_duration: 0
});
if(options.closing_message != null) {
run_timeline.push({
type: 'instructions',
pages: [
options.closing_message
],
show_clickable_nav: true,
button_label_next: 'OK',
allow_backward: false
});
}
/*
run_timeline.push({
type: 'instructions',
pages: [
"<h1>Thank you!</h1>" +
"<p>This is the end of that block.</p>"
],
show_clickable_nav: true,
button_label_next: 'Next'
});
*/
return {timeline: run_timeline};
}