jsPsychを使ったPsychomotor Vigilance Task (PVT)

Push button on the center of the screenjspsych

スマホを使ったオンライン調査でもPVT(Psychomotor Vigilance Task)がやりたいという相談を受けて書いてみたコードを、備忘録として残しておく。
※実際に実験に使ったコードではないので参考程度にお願いします。

PVTは反応時間を測る課題でタイミング制御が重要なので、ある程度評価が確立されているjsPsychを使うことにした。jsPsychはJavaScriptで書かれたライブラリで、WEBブラウザ上で動作する心理実験を比較的簡単に?書くことができる。
もともとPsychoPyに精通していたら、それのJavaScript版であるPsychoJSを使う選択肢もあったかもしれない。

ちなみに、ラボそしてオンラインでの心理実験に使うツールを比較した研究(https://doi.org/10.7717/peerj.9414)があって、基本的にはここを参考にjsPsychを選定した。

jsPsychをスマホで使う

jsPsychはPCブラウザ用に書かれたライブラリで、スマホで動作させるときに問題になったのがボタンタップの拾い方だった。

もともとjspsych-html-button-response.jsでは、addEventListener('click', ...としてボタンを準備している。このままスマホで使うと、ボタンをタッチした瞬間ではなくタッチしたあとに離した瞬間がボタン押しのタイミングとして記録されてしまう。

なのでこの箇所を、addEventListener('touchstart', ...に書き換えてみて、ひとまずちゃんとタッチした瞬間を記録しているっぽいので良しとしている。が、もし読者の方でご指摘あればぜひ教えていただけるとありがたいです。

コード本体

いずれもう少し中身を整理して公開したい。が、今はこのまま

注意事項など

  • jsPsychのバージョンは6.3を使っていた。(ので、今の最新バージョンで動くかはわかりません)
  • このコードはwww.cognition.run上で動作させるように書いたので、jsPsychの読み込みなどHTML部分は省略されていることに注意。
  • 細部の詰めが甘い点が複数あるので、このまま実験には使えないです。
  • このコードを使って実験が失敗しても筆者は責任を負いません。自己責任でお願いします。
// experiment settings
const testDurM  =    3;      // test duration in minutes
const testDur   = 1000 * 60 * testDurM; //convert testDur to msec
const isiMin    = 1000;      // minimum ISI in msec
const isiMax    = 4000;      // maximum ISI in msec
const maxDur    = 1000 * 30; // maximum length for clock trial
const fbDur     = 1000 * 1.5;// display length for RT feedback
const TH_fStart =  100;      // if(RT < TH_fStart) { respType: false_start}
const tmpTH_lps =  355;      // if(RT ≥ tempTH_lps){ respType: lapse } 

const id = jsPsych.data.getURLVariable('id');
const sessionID = jsPsych.randomization.randomID(20);
let testStart = 0;

// log properties
jsPsych.data.addProperties({
  ISIminimum: isiMin,
  ISImaximum: isiMax,
  testDuration: testDur,
  maxTrialDuration: maxDur,
  feedbackTrialDuration: fbDur,
  session: sessionID,
});

// original function(s)
function getRandomInt(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.round(Math.random() * (max - min) + min);
};

// change button styles
const PUSHstyle = '<style> .jspsych-btn {font-size:3.5em; position:fixed; bottom:15vh; left:50%; transform:translate(-50%);} </style>';
const CONTstyle = '<style> .jspsych-btn {font-size:2em; position:fixed; bottom:15vh; left:50%; transform:translate(-50%);} </style>';

/* create timeline */
var timeline = [];

/* welcome message and enter fullscreen*/
var welcome = {
  type: "fullscreen",
  fullscreen_mode: true,
  message: '<h2>PVT検査</h2><p style = font-size:2ex>id = ' + id +
    '<br>これからPVT課題を実施していただきます。' +
    'スマートフォンでの参加をお願いいたします。' +
    '<br><br>所要時間は約' + testDurM + '分です。</p>' + CONTstyle,
  //css_classes: '.jspsych-btn {bottom: -0em}',
  button_label: ['次に進む'],
  data: { stimType: 'welcomeMsg' },
  /* prompt:'<p>Is this activity</p>' */
};
timeline.push(welcome);

/*
// ask sleepiness
const KSSqs = [
  "非常にはっきり目覚めている",
  " ",
  "目覚めている",
  " ",
  "どちらでもない",
  " ",
  "眠い",
  " ",
  "とても眠い(眠気と戦っている)"
];
var KSS_trial = {
  type: 'survey-multi-choice',
  preamble: '<style> .jspsych-btn {font-size:2em} </style>',
  questions: [
    {
      prompt: "今の眠気を以下の9段階でお答えください。",
      name: 'KSS',
      options: KSSqs,
      required: true,
      horizontal: false,
    }
  ],
  button_label: ['次に進む'],
  data: { stimType: 'KSS' },
  autocomplete: false,
  force_response: true,
};
timeline.push(KSS_trial);

// ask predicted performance
var prdct_performance = {
  type: "survey-html-form",
  premble: 'performance prediction',
  button_label: '次に進む',
  html: '<p style = font-size:2ex> Mean reaction time <input type="text" id="resp-box" name="prdctRT" pattern="^([0-9]{3,})$" /></p>' + 
  '<style> .jspsych-btn {font-size:2em} </style>',
  autofocus: 'resp-box'
}
timeline.push(prdct_performance);
*/

var inst_trial = {
  type: "html-button-response-m",
  stimulus: '<p style = font-size:2ex>これから白い枠とPUSHボタンが表示されます。白い枠の中に数字が表示されたらなるべく早くPUSHボタンをタップしてください。' +
    '<br>PUSHボタンを押すと開始します。</p>' + PUSHstyle,
  choices: ['PUSH'],
  on_finish: function () {
    testStart = performance.now();
  },
  data: { stimType: 'instTrial' },
};
timeline.push(inst_trial);

// ISI
let t_dur         = 0;      // temp val
let t_dur_left    = 0;      // temp val
let presentAgain  = false;

/// ISI_child
var isi_trial_child = {
  type: 'html-button-response-m',
  stimulus: '<div id="clock"> </div>',
  choices: ['PUSH'],
  data: { stimType: 'ISI' },
  prompt: '<div id="prompt">課題実施中...</div>' + PUSHstyle,
  response_ends_trial: true,
  trial_duration: function() {
    return t_dur;
  },
  on_start: function() {
    console.log("ISI_child_started t_dur = " + t_dur);
  },
  on_finish: function (data) {
    if (!(data.rt === null)) {// button push occured
      // log RRT
      let lastRRT = 1 / data.rt * 1000;
      data.rrt = lastRRT;
      // for repeating isi_trial_child
      t_dur_left = t_dur - data.rt;
      console.log('false_start: t_dur_left = ' + t_dur_left);
      presentAgain  = true;
      jsPsych.data.get().addToLast({respType: 'false_start'});
    } else { // button push did not occured
     //本来なら、ボタン押しなくMaxDurに到達した際は、MaxDurがRTとなるのでそのあたりの処理の
      t_dur_left = 0;
      presentAgain  = false;
    }
  }
};

/// parent ISI loop
var isi_trial = {
  timeline: [isi_trial_child],
  on_timeline_start: function () {
    if(!presentAgain){
      t_dur = getRandomInt(isiMin, isiMax);
      console.log('init ISI with t_dur = ' + t_dur);
    } else {
      t_dur = t_dur_left;
      console.log('repeating ISI with t_dur = ' + t_dur);
    }
  },
  loop_function: function(data){
    if(presentAgain && t_dur_left > 0){
      return true;
    } else {
      return false;
    }
  }
};

let start_time = performance.now();
var interval = [];
let lastRT = 0;
// main trial
var main_trial = {
  type: 'html-button-response-m',
  stimulus: '<div id="clock"> </div>',
  choices: ['PUSH'],
  trial_duration: maxDur,
  response_ends_trial: true,
  data: { stimType: 'clock' },
  prompt: '<div id="prompt">課題実施中...</div>' + PUSHstyle,
  on_start: function () {
    start_time = performance.now();
    interval = setInterval(function () {
      var time_elapsed = Math.floor(performance.now() - start_time);
      var time_elapsed_str = time_elapsed.toString();
      document.querySelector('#clock').innerHTML = time_elapsed_str;
    }, 1)
  },
  on_finish: function (data) {
    clearInterval(interval);
    // log response type
    if (!(data.rt === null)) {
      let lastRRT = 1 / data.rt * 1000;
      //console.log(lastRRT);
      data.rrt = lastRRT;
      if(data.rt <= TH_fStart){
        jsPsych.data.get().addToLast({respType: 'false_start'});
      }else if(data.rt > tmpTH_lps){
        jsPsych.data.get().addToLast({respType: 'lapse_rt≥' + tmpTH_lps});
      }else {
        jsPsych.data.get().addToLast({respType: 'hit'});
      }
    } else {
      jsPsych.data.get().addToLast({respType: 'noResp_in' + maxDur + 'ms'})
    }

  },
  post_trial_gap: 0
};

//feedback
var fb_trial = {
  type: 'html-button-response-m',
  stimulus: '<div id="clock"> </div>',
  choices: ['PUSH'],
  prompt: '<div id="prompt">課題実施中...</div>' + PUSHstyle,
  trial_duration: fbDur,
  response_ends_trial: false,
  data: { stimType: 'fb' },
  on_start: function () {
    lastRT = jsPsych.data.get().last(1).select('rt')
    interval = setInterval(function () {
      document.querySelector("#clock").innerHTML = Math.floor(lastRT.values).toString();
      // activate pressed button during trial
      if (document.querySelector('button').disabled) {
        document.querySelector('button').disabled = false;
      }
    }, 10);
  },
  on_finish: function () {
    clearInterval(interval);
  }
};

// timeline
var PVT_timeline = {
  timeline: [isi_trial, main_trial, fb_trial],
  randomize_order: false,
  loop_function: function (data) {
    let et = performance.now() - testStart;
    if (et > testDur) {
      return false; // end test loop
    } else {
      return true;  // continue loop
    }
  }
};
timeline.push(PVT_timeline);

// finish
var endFullscreen = {
  type: 'fullscreen',
  fullscreen_mode: false,
  button_label: ['終了'],
  data: { stimType: 'endFullscreen' },
};
timeline.push(endFullscreen);

// end message
var endMsg = {
  type: 'html-button-response',
  stimulus: function () {
    let trialDat = jsPsych.data.get().filter({ stimType: 'clock' });
    let meanRT = Math.round(trialDat.select('rt').mean());
    return '<p>お疲れさまでした。<br>ボタンを押して終了します。<br>' +
      'mean RT = ' + meanRT + ' ms</p>' + CONTstyle
  },
  choices: ['終了'],
  trial_duration: 10000,
  data: { stimType: 'endMsg' },
};
timeline.push(endMsg);

/* start the experiment */
jsPsych.init({
  timeline: timeline,
  on_finish: function () {
    confirm('データを記録しました。ブラウザを閉じても大丈夫です。')
    // jsPsych.data.displayData('csv'); //終了後CSV形式でデータを画面上に表示
    // jsPsych.data.get().localSave('csv', 'data.csv');
  }
});

ちなみに、同時に読み込んでいたカスタムCSSはこちら

body {
    background-color: black;
    color: white;
}

p {
    font-size: 4ex;
    padding: 0, 0, 0, 0;
    line-height: 1.4em;
    color: white;
    
}

#clock {
    border: 2px solid whitesmoke;
    width: 3em;
    height: 1.1em;
    position: fixed;
    left: 50%;
    transform:translate(-50%);
    top: 40vh;
    margin: 0 auto;
    color: red;
    font-size: 5em;
    line-height: 1em;
}

#prompt {
    color: white;
    position: fixed;
    top: 20vh;
    left: 50%;
    transform:translate(-50%);
    font-size: 2ex;
    text-align: center;
    margin: 0 auto;
}