diff --git a/backend/experiment/actions/__init__.py b/backend/experiment/actions/__init__.py
index 37d7a3fa1..74879c6d5 100644
--- a/backend/experiment/actions/__init__.py
+++ b/backend/experiment/actions/__init__.py
@@ -1,15 +1,13 @@
+from .consent import Consent
from .explainer import Explainer, Step
from .form import *
from .final import Final
-from .score import Score
-from .song_sync import SongSync
-from .consent import Consent
+from .html import HTML
+from .info import Info
from .playback import Playback
from .playlist import Playlist
-from .start_session import StartSession
-from .trial import Trial
-from .info import Info
-from .plink import Plink
from .redirect import Redirect
-from .html import HTML
+from .score import Score
+from .start_session import StartSession
from .toontjehoger import ToontjeHoger
+from .trial import Trial
diff --git a/backend/experiment/actions/form.py b/backend/experiment/actions/form.py
index b7958b2e1..6dd7257de 100644
--- a/backend/experiment/actions/form.py
+++ b/backend/experiment/actions/form.py
@@ -1,7 +1,7 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _
-from .styles import STYLE_NEUTRAL
+from .styles import STYLE_NEUTRAL, STYLE_BOOLEAN_NEGATIVE_FIRST, STYLE_GRADIENT_7
from .base_action import BaseAction
class Question(BaseAction):
@@ -64,10 +64,11 @@ class BooleanQuestion(Question):
def __init__(self, choices=None, **kwargs):
super().__init__(**kwargs)
self.choices = choices or {
- 'yes': _('Yes'),
- 'no': _('No')
+ 'no': _('No'),
+ 'yes': _('Yes')
}
self.view = 'BUTTON_ARRAY'
+ self.style = {STYLE_BOOLEAN_NEGATIVE_FIRST: True, 'buttons-large-gap': True}
class ChoiceQuestion(Question):
def __init__(self, choices, min_values=1, **kwargs):
@@ -156,15 +157,7 @@ def __init__(self, scale_steps=7, likert_view='ICON_RANGE', **kwargs):
6: 'fa-face-frown-open',
7: 'fa-face-angry',
}
- self.config = {'icons':True, 'colors': ['#d843e2', '#c863e8', '#bb7ae9','#ab86f1', '#8b9bfa', '#42b5ff', '#0CC7F1']}
- elif scale_steps == 5:
- self.choices = {
- 1: _("Strongly Disagree"),
- 2: _("Disagree"),
- 3: _("Neither Agree nor Disagree"), # Undecided
- 4: _("Agree"),
- 5: _("Strongly Agree"),
- }
+ self.style = STYLE_GRADIENT_7
class Form(BaseAction):
''' Form is a view which brings together an array of questions with submit and optional skip button
diff --git a/backend/experiment/actions/plink.py b/backend/experiment/actions/plink.py
deleted file mode 100644
index 72d839dce..000000000
--- a/backend/experiment/actions/plink.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from django.utils.translation import gettext_lazy as _
-
-
-class Plink(object): # pylint: disable=too-few-public-methods
- """
- A custom view that handles a Plink question
- Relates to client component: Plink.js
- This question type is related to ToontjeHoger experiment 3
- """
-
- ID = 'PLINK'
-
- def __init__(self, section, main_question, choices, submit_label, dont_know_label, extra_questions=[], extra_questions_intro=None, title='', result_id=''):
- """
- - section: A section
- - main_question: Main question
- - choices: Question choices
- - dont_know_label: Don't know button label
- - submit_label: Submit button label
- - extra_questions: A list of additional questions
- - extra_questions_intro: Explainer view that explains the extra questions
- - title: Page title
- - result_id: Result id
- """
- self.section = section.simple_object()
- self.title = title
- self.main_question = main_question
- self.choices = choices
- self.submit_label = submit_label
- self.dont_know_label = dont_know_label
- self.extra_questions = extra_questions
- self.extra_questions_intro = extra_questions_intro
- self.result_id = result_id
-
- def action(self):
- """
- Serialize data for experiment action
- """
- # Create action
- action = {
- 'view': Plink.ID,
- 'title': self.title,
- 'result_id': self.result_id,
- 'section': self.section,
- 'mainQuestion': self.main_question,
- 'choices': self.choices,
- 'submitLabel': self.submit_label,
- 'dontKnowLabel': self.dont_know_label,
- 'extraQuestions': self.extra_questions,
- 'extraQuestionsIntro': self.extra_questions_intro
- }
-
- return action
-
- def extract_main_question(self, data):
- """Helper that extracts main_question from the given data"""
- return data.get('main_question', '') if type(data) is dict else ''
-
- def extract_extra_questions(self, data):
- """Helper that extracts extra_questions from the given data"""
- extra_questions = data.get('extra_questions') if type(data) is dict else []
- if not isinstance(extra_questions, list):
- return None
-
- return extra_questions
diff --git a/backend/experiment/actions/score.py b/backend/experiment/actions/score.py
index e30046182..7888d5fca 100644
--- a/backend/experiment/actions/score.py
+++ b/backend/experiment/actions/score.py
@@ -13,7 +13,7 @@ class Score(BaseAction): # pylint: disable=too-few-public-methods
ID = 'SCORE'
- def __init__(self, session, title=None, score_message=None, config=None, icon=None, timer=None, feedback=None):
+ def __init__(self, session, title=None, score=None, score_message=None, config=None, icon=None, timer=None, feedback=None):
""" Score presents feedback to a participant after a Trial
- session: a Session object
- title: the title of the score page
@@ -27,7 +27,7 @@ def __init__(self, session, title=None, score_message=None, config=None, icon=No
"""
self.session = session
self.title = title
- self.score = session.last_score()
+ self.score = score or session.last_score()
self.score_message = score_message or self.default_score_message
self.feedback = feedback
self.config = {
diff --git a/backend/experiment/actions/song_sync.py b/backend/experiment/actions/song_sync.py
deleted file mode 100644
index 05bb47370..000000000
--- a/backend/experiment/actions/song_sync.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import random
-
-from django.utils.translation import gettext_lazy as _
-
-from .base_action import BaseAction
-
-class SongSync(BaseAction): # pylint: disable=too-few-public-methods
- """
- Provide data for a SongSync view that handles views for song recognition,
- a silence and in- or out-sync continuation of audio playback
- """
- ID = 'SONG_SYNC'
-
- def __init__(self, section, key, result_id, title=None, config=None, instructions=None, buttons=None):
- '''
- initialize SongSync, with the following arguments:
- - section: section to be played during the round
- - title: title of the page
- - config: optional settings to override the default config
- - play_method:
- - 'BUFFER': Use webaudio buffers. (recommended for stimuli up to 45s)
- - 'HTML': Use the HTML tag. (recommended for stimuli longer than 45s)
- - 'EXTERNAL': Use for externally hosted audio files. Web-audio api will be disabled
- - instructions: optional instructions to override the default instructions
- - buttons: optional button labels to override the default labels
- '''
- self.section = section
- self.key = key
- self.result_id = result_id
- continuation_correctness = random.randint(0, 1) == 1
- self.config = {
- 'ready_time': 3,
- 'recognition_time': 15,
- 'silence_time': 4,
- 'sync_time': 15,
- 'continuation_offset': random.randint(100, 150) / 10 if not continuation_correctness else 0,
- 'continuation_correctness': continuation_correctness,
- 'play_method': 'BUFFER'
- }
- if config:
- self.config.update(config)
- self.instructions = {
- 'recognize': _('Do you recognise this song?'),
- 'imagine': _('Keep imagining the music'),
- 'correct': _('Did the track come back in the right place?'),
- 'ready': _('Get ready!')
- }
- if instructions:
- self.instructions.update(instructions)
- self.buttons = {
- 'yes': _('Yes'),
- 'no': _('No')
- }
- self.title = title
- if buttons:
- self.buttons.update(buttons)
-
- def action(self):
- """Serialize data for song_sync action"""
- section = [{'id': self.section.id,
- 'url': self.section.absolute_url(),
- 'group': self.section.group}]
- # Create action
- action = {
- 'view': self.ID,
- 'section': section,
- 'result_id': self.result_id,
- 'config': self.config,
- 'key': self.key,
- 'title': self.title,
- 'instructions': self.instructions,
- 'buttons': self.buttons,
- }
-
- return action
diff --git a/backend/experiment/actions/styles.py b/backend/experiment/actions/styles.py
index fcdbc04cd..22ae0a248 100644
--- a/backend/experiment/actions/styles.py
+++ b/backend/experiment/actions/styles.py
@@ -3,4 +3,5 @@
STYLE_BLUE = 'blue'
STYLE_PINK = 'pink'
STYLE_BOOLEAN = 'boolean'
-STYLE_BOOLEAN_NEGATIVE_FIRST = 'boolean-negative-first'
\ No newline at end of file
+STYLE_BOOLEAN_NEGATIVE_FIRST = 'boolean-negative-first'
+STYLE_GRADIENT_7 = 'gradient-7'
\ No newline at end of file
diff --git a/backend/experiment/actions/trial.py b/backend/experiment/actions/trial.py
index 20a75036b..1fa48fa8d 100644
--- a/backend/experiment/actions/trial.py
+++ b/backend/experiment/actions/trial.py
@@ -27,9 +27,7 @@ def __init__(self, playback=None, html=None, feedback_form=None, title='', confi
- response_time: how long to wait until stopping the player / proceeding to the next view
- auto_advance: proceed to next view after player has stopped
- listen_first: whether participant can submit before end of sound
- - time_pass_break: when time has passed, submit the result immediately; skipping any subsequent actions (e.g. a certainty question)
- - Can not be combined with listen_first (True)
- - Can not be combined with auto_advance (False)
+ - break_round_on: result values upon which consecutive rounds in the current next_round array will be skipped
- continue_label: if there is no form, how to label a button to proceed to next view
- style: style class to add to elements in form and playback
- neutral: first element is blue, second is yellow, third is teal
diff --git a/backend/experiment/actions/wrappers.py b/backend/experiment/actions/wrappers.py
index 2629dbf04..90891fe63 100644
--- a/backend/experiment/actions/wrappers.py
+++ b/backend/experiment/actions/wrappers.py
@@ -1,11 +1,15 @@
+import random
+
from django.utils.translation import gettext as _
-from .form import ChoiceQuestion, Form
+from .form import BooleanQuestion, ChoiceQuestion, Form
from .playback import Playback
from .trial import Trial
from result.utils import prepare_result
+from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST
+
def two_alternative_forced(session, section, choices, expected_response=None, style={}, comment='', scoring_rule=None, title='', config=None):
"""
@@ -17,7 +21,7 @@ def two_alternative_forced(session, section, choices, expected_response=None, st
'BUTTON'
)
key = 'choice'
- button_style = {'invisible-text': True, 'buttons-large-gap': True}
+ button_style = {'invisible-text': True, 'buttons-large-gap': True, 'buttons-large-text': True}
button_style.update(style)
question = ChoiceQuestion(
key=key,
@@ -37,3 +41,64 @@ def two_alternative_forced(session, section, choices, expected_response=None, st
feedback_form = Form([question])
trial = Trial(playback=playback, feedback_form=feedback_form, title=title, config=config)
return trial
+
+def song_sync(session, section, title, response_time=15, play_method='BUFFER'):
+ trial_config = {
+ 'response_time': response_time,
+ 'auto_advance': True
+ }
+ recognize = Trial(
+ feedback_form=Form([BooleanQuestion(
+ key='recognize',
+ result_id=prepare_result('recognize', session, section=section, scoring_rule='SONG_SYNC_RECOGNITION'),
+ submits=True
+ )]),
+ playback=Playback([section], 'AUTOPLAY', play_config={
+ 'ready_time': 3,
+ 'show_animation': True,
+ 'play_method': play_method
+ },
+ preload_message=_('Get ready!'),
+ instruction=_('Do you recognize the song?'),
+ ),
+ config={**trial_config, 'break_round_on': {'EQUALS': ['TIMEOUT', 'no']}},
+ title=title
+ )
+ silence_time = 4
+ silence = Trial(
+ playback=Playback([section], 'AUTOPLAY',
+ instruction=_('Keep imagining the music'),
+ play_config={
+ 'mute': True,
+ 'ready_time': 0,
+ 'show_animation': True,
+ }),
+ config={
+ 'response_time': silence_time,
+ 'auto_advance': True,
+ 'show_continue_button': False
+ },
+ title=title
+ )
+ continuation_correctness = random.randint(0, 1) == 1
+ correct_place = Trial(
+ feedback_form=Form([BooleanQuestion(
+ key='correct_place',
+ submits=True,
+ result_id=prepare_result('correct_place',
+ session,
+ section=section,
+ scoring_rule='SONG_SYNC_CONTINUATION',
+ expected_response='yes' if continuation_correctness else 'no')
+ )]),
+ playback=Playback([section], 'AUTOPLAY',
+ instruction=_('Did the track come back in the right place?'),
+ play_config={
+ 'ready_time': 0,
+ 'playhead': silence_time + (random.randint(100, 150) / 10 if not continuation_correctness else 0),
+ 'show_animation': True
+ }),
+ config=trial_config,
+ title=title
+ )
+ return [recognize, silence, correct_place]
diff --git a/backend/experiment/rules/__init__.py b/backend/experiment/rules/__init__.py
index 404dddaa1..f9323dd1b 100644
--- a/backend/experiment/rules/__init__.py
+++ b/backend/experiment/rules/__init__.py
@@ -8,6 +8,7 @@
from .h_bat import HBat
from .h_bat_bfit import HBatBFIT
from .hbat_bst import BST
+from .hooked import Hooked
from .huang_2022 import Huang2022
from .kuiper_2020 import Kuiper2020
from .listening_conditions import ListeningConditions
@@ -41,6 +42,7 @@
HBat.ID: HBat,
HBatBFIT.ID: HBatBFIT,
BST.ID: BST,
+ Hooked.ID: Hooked,
MatchingPairs.ID: MatchingPairs,
MatchingPairsICMPC.ID: MatchingPairsICMPC,
MusicalPreferences.ID: MusicalPreferences,
diff --git a/backend/experiment/rules/categorization.py b/backend/experiment/rules/categorization.py
index ca248efb7..66ef5a5ed 100644
--- a/backend/experiment/rules/categorization.py
+++ b/backend/experiment/rules/categorization.py
@@ -508,5 +508,5 @@ def get_title(self, session):
},
submits=True,
is_skippable=False,
- style={'buttons-large-gap': True, 'boolean': True}
+ style={'buttons-large-gap': True, 'buttons-large-text': True, 'boolean': True}
)
diff --git a/backend/experiment/rules/eurovision_2020.py b/backend/experiment/rules/eurovision_2020.py
index 9af527c07..2952268ea 100644
--- a/backend/experiment/rules/eurovision_2020.py
+++ b/backend/experiment/rules/eurovision_2020.py
@@ -1,10 +1,11 @@
from .hooked import Hooked
import random
from django.utils.translation import gettext_lazy as _
-from experiment.actions import SongSync, Trial
+from experiment.actions import Trial
from experiment.actions.playback import Playback
from experiment.actions.form import BooleanQuestion, Form
from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST
+from experiment.actions.wrappers import song_sync
from result.utils import prepare_result
@@ -89,7 +90,7 @@ def next_song_sync_action(self, session):
"""Get next song_sync section for this session."""
# Load plan.
- next_round_number = session.get_next_round()
+ round_number = self.get_current_round(session)
try:
plan = session.load_json_data()['plan']
songs = plan['songs']
@@ -100,30 +101,21 @@ def next_song_sync_action(self, session):
# Get section.
section = None
- if next_round_number <= len(songs) and next_round_number <= len(tags):
+ if round_number <= len(songs) and round_number <= len(tags):
section = \
- session.section_from_song(
- songs[next_round_number - 1],
- {'tag': tags[next_round_number - 1]}
+ session.playlist.get_section(
+ {'tag': str(tags[round_number])},
+ [songs[round_number]]
)
if not section:
print("Warning: no next_song_sync section found")
- section = session.section_from_any_song()
- key = 'song_sync'
- result_id = prepare_result(key, session, section=section, scoring_rule='SONG_SYNC')
- return SongSync(
- key=key,
- section=section,
- title=self.get_trial_title(session, next_round_number),
- config = {'play_method': self.play_method},
- result_id=result_id
- )
+ section = session.playlist.get_section()
+ return song_sync(session, section, title=self.get_trial_title(session, round_number))
def next_heard_before_action(self, session):
"""Get next heard_before action for this session."""
# Load plan.
- next_round_number = session.get_next_round()
try:
plan = session.load_json_data()['plan']
songs = plan['songs']
@@ -133,23 +125,25 @@ def next_heard_before_action(self, session):
print('Missing plan key: %s' % str(error))
return None
+ round_number = self.get_current_round(session)
+
# Get section.
section = None
- if next_round_number <= len(songs) and next_round_number <= len(tags):
+ if round_number <= len(songs) and round_number <= len(tags):
section = \
- session.section_from_song(
- songs[next_round_number - 1],
- {'tag': tags[next_round_number - 1]}
+ session.playlist.get_section(
+ {'tag': str(tags[round_number])},
+ [songs[round_number]]
)
if not section:
print("Warning: no heard_before section found")
- section = session.section_from_any_song()
+ section = session.playlist.get_section()
playback = Playback(
sections = [section],
play_config={'ready_time': 3, 'show_animation': True, 'play_method': self.play_method},
preload_message=_('Get ready!'))
- expected_result=int(novelty[next_round_number - 1] == 'old')
+ expected_result=int(novelty[round_number] == 'old')
# create Result object and save expected result to database
result_pk = prepare_result('heard_before', session, section=section, expected_response=expected_result, scoring_rule='REACTION_TIME')
form = Form([BooleanQuestion(
@@ -167,7 +161,7 @@ def next_heard_before_action(self, session):
'decision_time': self.timeout
}
trial = Trial(
- title=self.get_trial_title(session, next_round_number),
+ title=self.get_trial_title(session, round_number),
playback=playback,
feedback_form=form,
config=config,
diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py
index f83c9f6d3..680501a12 100644
--- a/backend/experiment/rules/hooked.py
+++ b/backend/experiment/rules/hooked.py
@@ -1,5 +1,3 @@
-from copy import deepcopy
-from itertools import chain
import logging
import random
@@ -8,7 +6,7 @@
from django.template.loader import render_to_string
from .base import Base
-from experiment.actions import Consent, Explainer, Final, Playlist, Score, SongSync, StartSession, Step, Trial
+from experiment.actions import Consent, Explainer, Final, Playlist, Score, StartSession, Step, Trial
from experiment.actions.form import BooleanQuestion, Form
from experiment.actions.playback import Playback
from experiment.questions.demographics import DEMOGRAPHICS
@@ -19,16 +17,20 @@
from experiment.questions.stomp import STOMP20
from experiment.questions.tipi import TIPI
from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST
+from experiment.actions.wrappers import song_sync
from result.utils import prepare_result
+
logger = logging.getLogger(__name__)
class Hooked(Base):
"""Superclass for Hooked experiment rules"""
+ ID = 'HOOKED'
consent_file = 'consent_hooked.html';
timeout = 15
questions = True
+ relevant_keys = ['recognize', 'heard_before']
round_modifier = 0
play_method = 'BUFFER'
@@ -60,7 +62,7 @@ def first_round(self, experiment):
button_label=_("Let's go!"))
# 2. Get informed consent.
- if self.consent_file:
+ if self.consent_file:
rendered = render_to_string('consent/{}'.format(self.consent_file))
consent = Consent(text=rendered, title=_('Informed consent'), confirm=_('I agree'), deny=_('Stop'))
else:
@@ -83,30 +85,25 @@ def first_round(self, experiment):
def next_round(self, session):
"""Get action data for the next round"""
json_data = session.load_json_data()
+ round_number = self.get_current_round(session)
# If the number of results equals the number of experiment.rounds,
# close the session and return data for the final_score view.
- if session.rounds_complete():
+ if round_number == session.experiment.rounds:
# Finish session.
session.finish()
session.save()
# Return a score and final score action.
- next_round_number = session.get_next_round()
- config = {'show_section': True, 'show_total_score': True}
- title = self.get_trial_title(session, next_round_number - 1)
- social_info = self.social_media_info(session.experiment, session.final_score)
+ total_score = session.total_score()
return [
- Score(session,
- config=config,
- title=title
- ),
+ self.get_score(session, round_number),
Final(
session=session,
final_text=self.final_score_message(session),
rank=self.rank(session),
- social=social_info,
+ social=self.social_media_info(session.experiment, total_score),
show_profile_link=True,
button={'text': _('Play again'), 'link': '{}/{}'.format(settings.CORS_ORIGIN_WHITELIST[0], session.experiment.slug)}
)
@@ -114,45 +111,39 @@ def next_round(self, session):
# Get next round number and initialise actions list. Two thirds of
# rounds will be song_sync; the remainder heard_before.
- next_round_number = session.get_next_round()
# Collect actions.
actions = []
- if next_round_number == 1:
+ if round_number == 0:
# Plan sections
self.plan_sections(session)
# Go to SongSync straight away.
- actions.append(self.next_song_sync_action(session))
+ actions.extend(self.next_song_sync_action(session))
else:
# Create a score action.
- config = {'show_section': True, 'show_total_score': True}
- title = self.get_trial_title(session, next_round_number - 1)
- actions.append(Score(session,
- config=config,
- title=title
- ))
+ actions.append(self.get_score(session, round_number))
# Load the heard_before offset.
plan = json_data.get('plan')
- heard_before_offset = plan['n_song_sync'] + 1
+ heard_before_offset = plan['n_song_sync']
# SongSync rounds. Skip questions until Round 5.
- if next_round_number in range(2, 5):
- actions.append(self.next_song_sync_action(session))
- if next_round_number in range(5, heard_before_offset):
+ if round_number in range(1, 5):
+ actions.extend(self.next_song_sync_action(session))
+ if round_number in range(5, heard_before_offset):
question_trial = self.get_single_question(session)
if question_trial:
actions.append(question_trial)
- actions.append(self.next_song_sync_action(session))
+ actions.extend(self.next_song_sync_action(session))
# HeardBefore rounds
- if next_round_number == heard_before_offset:
+ if round_number == heard_before_offset:
# Introduce new round type with Explainer.
actions.append(self.heard_before_explainer())
actions.append(
self.next_heard_before_action(session))
- if next_round_number > heard_before_offset:
+ if round_number > heard_before_offset:
question_trial = self.get_single_question(session)
if question_trial:
actions.append(question_trial)
@@ -161,6 +152,9 @@ def next_round(self, session):
return actions
+ def get_current_round(self, session):
+ return session.get_relevant_results(self.relevant_keys).count()
+
def heard_before_explainer(self):
"""Explainer for heard-before rounds"""
return Explainer(
@@ -209,9 +203,9 @@ def final_score_message(self, session):
n_old_new_correct, n_old_new_expected)
return score_message + " " + song_sync_message + " " + heard_before_message
- def get_trial_title(self, session, next_round_number):
+ def get_trial_title(self, session, round_number):
return _("Round %(number)d / %(total)d") %\
- {'number': next_round_number, 'total': session.experiment.rounds}
+ {'number': round_number+1, 'total': session.experiment.rounds}
def plan_sections(self, session, filter_by={}):
"""Set the plan of tracks for a session.
@@ -258,11 +252,11 @@ def plan_sections(self, session, filter_by={}):
# Save, overwriting existing plan if one exists.
session.save_json_data({'plan': plan})
- def next_song_sync_action(self, session):
+ def next_song_sync_action(self, session, explainers=[]):
"""Get next song_sync section for this session."""
# Load plan.
- next_round_number = session.get_current_round() - self.round_modifier
+ round_number = self.get_current_round(session) - self.round_modifier
try:
plan = session.load_json_data()['plan']
sections = plan['song_sync_sections']
@@ -272,28 +266,19 @@ def next_song_sync_action(self, session):
# Get section.
section = None
- if next_round_number <= len(sections):
+ if round_number <= len(sections):
section = \
- session.section_from_any_song(
- {'id': sections[next_round_number-1].get('id')})
+ session.playlist.section_set.get(
+ **{'id': sections[round_number-1].get('id')})
if not section:
logger.warning("Warning: no next_song_sync section found")
section = session.section_from_any_song()
- key = 'song_sync'
- result_id = prepare_result(key, session, section=section, scoring_rule='SONG_SYNC')
- return SongSync(
- section=section,
- title=self.get_trial_title(session, next_round_number),
- config = {'play_method': self.play_method},
- key=key,
- result_id=result_id
- )
-
+ return song_sync(session, section, title=self.get_trial_title(session, round_number), play_method=self.play_method)
+
def next_heard_before_action(self, session):
"""Get next heard_before action for this session."""
# Load plan.
- next_round_number = session.get_current_round() - self.round_modifier
try:
plan = session.load_json_data()['plan']
sections = plan['heard_before_sections']
@@ -302,11 +287,12 @@ def next_heard_before_action(self, session):
logger.error('Missing plan key: %s' % str(error))
return None
# Get section.
+ round_number = session.get_current_round() - self.round_modifier - heard_before_offset
section = None
- if next_round_number - heard_before_offset <= len(sections):
- this_section_info = sections[next_round_number - heard_before_offset]
- section = session.section_from_any_song(
- {'id': this_section_info.get('id')})
+ if round_number <= len(sections):
+ this_section_info = sections[round_number]
+ section = session.playlist.section_set.get(
+ **{'id': this_section_info.get('id')})
if not section:
logger.warning("Warning: no heard_before section found")
section = session.section_from_any_song()
@@ -333,9 +319,19 @@ def next_heard_before_action(self, session):
'response_time': self.timeout
}
trial = Trial(
- title=self.get_trial_title(session, next_round_number),
+ title=self.get_trial_title(session, round_number),
playback=playback,
feedback_form=form,
config=config,
)
return trial
+
+ def get_score(self, session, round_number):
+ config = {'show_section': True, 'show_total_score': True}
+ title = self.get_trial_title(session, round_number - 1)
+ previous_score = session.get_previous_result(self.relevant_keys).score
+ return Score(session,
+ config=config,
+ title=title,
+ score=previous_score
+ )
diff --git a/backend/experiment/rules/huang_2022.py b/backend/experiment/rules/huang_2022.py
index 75518cac2..d4ddfcfd4 100644
--- a/backend/experiment/rules/huang_2022.py
+++ b/backend/experiment/rules/huang_2022.py
@@ -23,7 +23,6 @@ class Huang2022(Hooked):
ID = 'HUANG_2022'
timeout = 15
- round_modifier = 2
contact_email = 'musicexp_china@163.com'
play_method = 'EXTERNAL'
@@ -73,107 +72,101 @@ def next_round(self, session):
json_data = session.load_json_data()
# Get next round number and initialise actions list. Two thirds of
# rounds will be song_sync; the remainder heard_before.
- next_round_number = session.get_current_round() - self.round_modifier
+ round_number = self.get_current_round(session)
total_rounds = session.experiment.rounds
# Collect actions.
actions = []
+ plan = json_data.get('plan')
- if next_round_number == -1:
- playback = get_test_playback(self.play_method)
- html = HTML(body='
{}
'.format(_('Do you hear the music?')))
- form = Form(form=[BooleanQuestion(
- key='audio_check1',
- choices={'no': _('No'), 'yes': _('Yes')},
- result_id=prepare_result('audio_check1', session,
- scoring_rule='BOOLEAN'),
- submits=True,
- style=STYLE_BOOLEAN_NEGATIVE_FIRST)])
- return Trial(playback=playback, feedback_form=form, html=html,
- config={'response_time': 15},
- title=_("Audio check"))
- elif next_round_number <= 1:
+ if not plan:
last_result = session.result_set.last()
- if last_result.question_key == 'audio_check1':
+ if not last_result:
+ playback = get_test_playback(self.play_method)
+ html = HTML(body='{}
'.format(_('Do you hear the music?')))
+ form = Form(form=[BooleanQuestion(
+ key='audio_check1',
+ choices={'no': _('No'), 'yes': _('Yes')},
+ result_id=prepare_result('audio_check1', session,
+ scoring_rule='BOOLEAN'),
+ submits=True,
+ style=STYLE_BOOLEAN_NEGATIVE_FIRST)])
+ return Trial(playback=playback, feedback_form=form, html=html,
+ config={'response_time': 15},
+ title=_("Audio check"))
+ else:
if last_result.score == 0:
- playback = get_test_playback(self.play_method)
- html = HTML(body=render_to_string('html/huang_2022/audio_check.html'))
- form = Form(form=[BooleanQuestion(
- key='audio_check2',
- choices={'no': _('Quit'), 'yes': _('Try')},
- result_id=prepare_result('audio_check2', session, scoring_rule='BOOLEAN'),
- submits=True,
- style=STYLE_BOOLEAN_NEGATIVE_FIRST
- )])
- return Trial(playback=playback, html=html, feedback_form=form,
- config={'response_time': 15},
- title=_("Ready to experiment"))
- else:
- session.increment_round() # adjust round numbering
- elif last_result.question_key == 'audio_check2' and last_result.score == 0:
- # participant had persistent audio problems, delete session and redirect
- session.finish()
- session.save()
- return Redirect(settings.HOMEPAGE)
-
- # Start experiment: plan sections and show explainers
- self.plan_sections(session)
- # Show explainers and go to SongSync
- explainer = Explainer(
- instruction=_("How to Play"),
- steps=[
- Step(_(
- "Do you recognise the song? Try to sing along. The faster you recognise songs, the more points you can earn.")),
- Step(_(
- "Do you really know the song? Keep singing or imagining the music while the sound is muted. The music is still playing: you just can’t hear it!")),
- Step(_(
- "Was the music in the right place when the sound came back? Or did we jump to a different spot during the silence?"))
- ],
- step_numbers=True,
- button_label=_("Let's go!"))
- explainer_devices = Explainer(
- instruction=_("You can use your smartphone, computer or tablet to participate in this experiment. Please choose the best network in your area to participate in the experiment, such as wireless network (WIFI), mobile data network signal (4G or above) or wired network. If the network is poor, it may cause the music to fail to load or the experiment may fail to run properly. You can access the experiment page through the following channels:"),
- steps=[
- Step(_(
- "Directly click the link on WeChat (smart phone or PC version, or WeChat Web)"),
- ),
- Step(_(
- "If the link to load the experiment page through the WeChat app on your cell phone fails, you can copy and paste the link in the browser of your cell phone or computer to participate in the experiment. You can use any of the currently available browsers, such as Safari, Firefox, 360, Google Chrome, Quark, etc."),
+ # user indicated they couldn't hear the music
+ if last_result.question_key == 'audio_check1':
+ playback = get_test_playback(self.play_method)
+ html = HTML(body=render_to_string('html/huang_2022/audio_check.html'))
+ form = Form(form=[BooleanQuestion(
+ key='audio_check2',
+ choices={'no': _('Quit'), 'yes': _('Try')},
+ result_id=prepare_result('audio_check2', session, scoring_rule='BOOLEAN'),
+ submits=True,
+ style=STYLE_BOOLEAN_NEGATIVE_FIRST
+ )])
+ return Trial(playback=playback, html=html, feedback_form=form,
+ config={'response_time': 15},
+ title=_("Ready to experiment"))
+ else:
+ # finish and redirect
+ session.finish()
+ session.save()
+ return Redirect(settings.HOMEPAGE)
+ if last_result.score == 1:
+ # Start experiment: plan sections and show explainers
+ self.plan_sections(session)
+ # Show explainers and go to SongSync
+ explainer = Explainer(
+ instruction=_("How to Play"),
+ steps=[
+ Step(_(
+ "Do you recognise the song? Try to sing along. The faster you recognise songs, the more points you can earn.")),
+ Step(_(
+ "Do you really know the song? Keep singing or imagining the music while the sound is muted. The music is still playing: you just can’t hear it!")),
+ Step(_(
+ "Was the music in the right place when the sound came back? Or did we jump to a different spot during the silence?"))
+ ],
+ step_numbers=True,
+ button_label=_("Let's go!"))
+ explainer_devices = Explainer(
+ instruction=_("You can use your smartphone, computer or tablet to participate in this experiment. Please choose the best network in your area to participate in the experiment, such as wireless network (WIFI), mobile data network signal (4G or above) or wired network. If the network is poor, it may cause the music to fail to load or the experiment may fail to run properly. You can access the experiment page through the following channels:"),
+ steps=[
+ Step(_(
+ "Directly click the link on WeChat (smart phone or PC version, or WeChat Web)"),
+ ),
+ Step(_(
+ "If the link to load the experiment page through the WeChat app on your cell phone fails, you can copy and paste the link in the browser of your cell phone or computer to participate in the experiment. You can use any of the currently available browsers, such as Safari, Firefox, 360, Google Chrome, Quark, etc."),
+ )
+ ],
+ step_numbers=True,
+ button_label=_("Continue")
)
- ],
- step_numbers=True,
- button_label=_("Continue")
- )
- # Choose playlist
- actions.extend([explainer, explainer_devices, self.next_song_sync_action(session)])
+ actions.extend([explainer, explainer_devices, *self.next_song_sync_action(session)])
else:
# Load the heard_before offset.
- plan = json_data.get('plan')
+
heard_before_offset = len(plan['song_sync_sections']) + 1
# show score
- config = {'show_section': True, 'show_total_score': True}
- title = self.get_trial_title(session, next_round_number - 1)
- score = Score(
- session,
- config=config,
- title=title
- )
+ score = self.get_score(session, round_number)
actions.append(score)
# SongSync rounds
- if next_round_number < heard_before_offset:
- actions.append(self.next_song_sync_action(session))
+ if round_number < heard_before_offset:
+ actions.extend(self.next_song_sync_action(session))
# HeardBefore rounds
- elif next_round_number == heard_before_offset:
+ elif round_number == heard_before_offset:
# Introduce new round type with Explainer.
actions.append(self.heard_before_explainer())
actions.append(
self.next_heard_before_action(session))
- elif heard_before_offset < next_round_number <= total_rounds:
+ elif heard_before_offset < round_number <= total_rounds:
actions.append(
self.next_heard_before_action(session))
- elif next_round_number == total_rounds + 1:
+ elif round_number == total_rounds + 1:
questionnaire = self.get_questionnaire(session)
if questionnaire:
actions.extend([Explainer(
diff --git a/backend/experiment/rules/kuiper_2020.py b/backend/experiment/rules/kuiper_2020.py
index 0b51e140c..e272034f5 100644
--- a/backend/experiment/rules/kuiper_2020.py
+++ b/backend/experiment/rules/kuiper_2020.py
@@ -1,10 +1,11 @@
import random
from django.utils.translation import gettext_lazy as _
-from experiment.actions import SongSync, Trial
+from experiment.actions import Trial
from experiment.actions.playback import Playback
from experiment.actions.form import BooleanQuestion, Form
from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST
+from experiment.actions.wrappers import song_sync
from result.utils import prepare_result
from .hooked import Hooked
@@ -78,8 +79,6 @@ def plan_sections(self, session):
)
}
- print(plan)
-
# Save, overwriting existing plan if one exists.
session.save_json_data({'plan': plan})
# session.save() is required for persistence
@@ -89,7 +88,7 @@ def next_song_sync_action(self, session):
"""Get next song_sync section for this session."""
# Load plan.
- next_round_number = session.get_next_round()
+ round_number = self.get_current_round(session)
try:
plan = session.load_json_data()['plan']
sections = plan['sections']
@@ -99,26 +98,18 @@ def next_song_sync_action(self, session):
# Get section.
section = None
- if next_round_number <= len(sections):
+ if round_number <= len(sections):
section = \
- session.section_from_any_song({'id': sections[next_round_number - 1]})
+ session.section_from_any_song({'id': sections[round_number - 1]})
if not section:
print("Warning: no next_song_sync section found")
section = session.section_from_any_song()
- key = 'song_sync'
- result_id = prepare_result(key, session, section=section, scoring_rule='SONG_SYNC')
- return SongSync(
- key=key,
- section=section,
- title=self.get_trial_title(session, next_round_number),
- result_id=result_id
- )
+ return song_sync(session, section, title=self.get_trial_title(session, round_number))
def next_heard_before_action(self, session):
"""Get next heard_before action for this session."""
# Load plan.
- next_round_number = session.get_next_round()
try:
plan = session.load_json_data()['plan']
sections = plan['sections']
@@ -126,12 +117,13 @@ def next_heard_before_action(self, session):
except KeyError as error:
print('Missing plan key: %s' % str(error))
return None
-
+
+ round_number = self.get_current_round(session)
# Get section.
section = None
- if next_round_number <= len(sections):
+ if round_number <= len(sections):
section = \
- session.section_from_any_song({'id': sections[next_round_number - 1]})
+ session.section_from_any_song({'id': sections[round_number]})
if not section:
print("Warning: no heard_before section found")
section = session.section_from_any_song()
@@ -140,7 +132,7 @@ def next_heard_before_action(self, session):
[section],
play_config={'ready_time': 3, 'show_animation': True},
preload_message=_('Get ready!'))
- expected_result=int(novelty[next_round_number - 1] == 'old')
+ expected_result=int(novelty[round_number] == 'old')
# create Result object and save expected result to database
result_pk = prepare_result('heard_before', session, section=section, expected_response=expected_result, scoring_rule='REACTION_TIME')
form = Form([BooleanQuestion(
@@ -159,7 +151,7 @@ def next_heard_before_action(self, session):
'decision_time': self.timeout
}
trial = Trial(
- title=self.get_trial_title(session, next_round_number),
+ title=self.get_trial_title(session, round_number),
playback=playback,
feedback_form=form,
config=config,
diff --git a/backend/experiment/rules/musical_preferences.py b/backend/experiment/rules/musical_preferences.py
index 6ed4441c7..1d265cbeb 100644
--- a/backend/experiment/rules/musical_preferences.py
+++ b/backend/experiment/rules/musical_preferences.py
@@ -188,7 +188,7 @@ def next_round(self, session, request_session=None):
top_all
)]
- section = session.section_from_unused_song()
+ section = session.playlist.get_section()
like_key = 'like_song'
likert = LikertQuestionIcon(
question=_('2. How much do you like this song?'),
diff --git a/backend/experiment/rules/tests/test_hooked.py b/backend/experiment/rules/tests/test_hooked.py
index 90b476e79..330ee6577 100644
--- a/backend/experiment/rules/tests/test_hooked.py
+++ b/backend/experiment/rules/tests/test_hooked.py
@@ -15,9 +15,9 @@ class HookedTest(TestCase):
def setUpTestData(cls):
''' set up data for Hooked base class '''
cls.participant = Participant.objects.create()
-
- def test_eurovision(self):
- experiment = Experiment.objects.get(name='Hooked-Eurovision')
+
+ def test_hooked(self):
+ experiment = Experiment.objects.create(name='Hooked', rules='HOOKED', rounds=3)
playlist = Playlist.objects.get(name='Eurovision 2021')
playlist.update_sections()
session = Session.objects.create(
@@ -27,11 +27,28 @@ def test_eurovision(self):
)
rules = session.experiment_rules()
rules.plan_sections(session)
- assert session.load_json_data().get('plan') != None
- action = rules.next_song_sync_action(session)
- assert action != None
+ plan = session.load_json_data().get('plan')
+ assert plan != None
+ actions = rules.next_song_sync_action(session)
+ assert len(actions) == 3
+ actions = rules.next_song_sync_action(session)
+ assert len(actions) == 3
action = rules.next_heard_before_action(session)
assert action != None
+
+ def test_eurovision(self):
+ experiment = Experiment.objects.get(name='Hooked-Eurovision')
+ playlist = Playlist.objects.get(name='Eurovision 2021')
+ playlist.update_sections()
+ session = Session.objects.create(
+ experiment=experiment,
+ participant=self.participant,
+ playlist=playlist
+ )
+ rules = session.experiment_rules()
+ for i in range(0, experiment.rounds):
+ actions = rules.next_round(session)
+ assert actions
def test_thats_my_song(self):
musicgen_keys = [q.key for q in MUSICGENS_17_W_VARIANTS]
@@ -46,12 +63,12 @@ def test_thats_my_song(self):
rules = session.experiment_rules()
assert rules.feedback_info() == None
- for i in range(1, experiment.rounds + 3):
+ for i in range(0, experiment.rounds):
actions = rules.next_round(session)
- if i == experiment.rounds + 2:
+ if i == experiment.rounds:
assert len(actions) == 2
assert actions[1].ID == 'FINAL'
- elif i == 1:
+ elif i == 0:
assert len(actions) == 3
assert actions[0].feedback_form.form[0].key == 'dgf_generation'
assert actions[1].feedback_form.form[0].key == 'dgf_gender_identity'
@@ -74,28 +91,30 @@ def test_thats_my_song(self):
)
gender.given_response = 'and another thing'
gender.save()
- elif i == 2:
- assert session.result_set.count() == 2
+ elif i == 1:
+ assert session.result_set.count() == 3
assert session.load_json_data().get('plan') != None
- assert len(actions) == 1
- assert actions[0].key == 'song_sync'
+ assert len(actions) == 3
+ assert actions[0].feedback_form.form[0].key == 'recognize'
+ assert actions[2].feedback_form.form[0].key == 'correct_place'
else:
plan = session.load_json_data().get('plan')
- heard_before_offset = len(plan['song_sync_sections']) + 2
+ heard_before_offset = len(plan['song_sync_sections'])
assert actions[0].ID == 'SCORE'
- if i < 6:
- assert len(actions) == 2
- assert actions[1].key == 'song_sync'
+ if i < 5:
+ assert len(actions) == 4
+ assert actions[1].feedback_form.form[0].key == 'recognize'
elif i < heard_before_offset:
- assert len(actions) == 3
+ assert len(actions) == 5
assert actions[1].feedback_form.form[0].key in musicgen_keys
elif i == heard_before_offset:
assert len(actions) == 3
assert actions[1].ID == 'EXPLAINER'
+ assert actions[2].feedback_form.form[0].key == 'heard_before'
else:
assert len(actions) == 3
- assert actions[2].feedback_form.form[0].key == 'heard_before'
- assert actions[1].feedback_form.form[0].key in musicgen_keys
+ assert actions[1].feedback_form.form[0].key == 'heard_before'
+ assert actions[2].feedback_form.form[0].key in musicgen_keys
session.increment_round()
def test_hooked_china(self):
diff --git a/backend/experiment/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py
index 6e2f5a7c9..250b438ec 100644
--- a/backend/experiment/rules/thats_my_song.py
+++ b/backend/experiment/rules/thats_my_song.py
@@ -1,7 +1,7 @@
from django.utils.translation import gettext_lazy as _
from django.conf import settings
-from experiment.actions import Final, Score, Trial
+from experiment.actions import Final, Trial
from experiment.actions.form import Form, ChoiceQuestion
from experiment.questions.utils import copy_shuffle, question_by_key
from experiment.questions.musicgens import MUSICGENS_17_W_VARIANTS
@@ -12,6 +12,7 @@ class ThatsMySong(Hooked):
ID = 'THATS_MY_SONG'
consent_file = None
+ relevant_keys = ['recognize', 'heard_before', 'playlist_decades']
round_modifier = 1
def __init__(self):
@@ -59,25 +60,20 @@ def first_round(self, experiment):
def next_round(self, session):
"""Get action data for the next round"""
json_data = session.load_json_data()
-
+ round_number = self.get_current_round(session)
+
# If the number of results equals the number of experiment.rounds,
# close the session and return data for the final_score view.
- if session.rounds_passed() == session.experiment.rounds + 1:
+ if round_number == session.experiment.rounds:
# Finish session.
session.finish()
session.save()
# Return a score and final score action.
- next_round_number = session.get_next_round()
- config = {'show_section': True, 'show_total_score': True}
social_info = self.social_media_info(session.experiment, session.final_score)
- title = self.get_trial_title(session, next_round_number - 1 - self.round_modifier)
return [
- Score(session,
- config=config,
- title=title
- ),
+ self.get_score(session, round_number),
Final(
session=session,
final_text=self.final_score_message(session) + " For more information about this experiment, visit the Vanderbilt University Medical Center Music Cognition Lab.",
@@ -90,13 +86,12 @@ def next_round(self, session):
)
]
- # Get next round number and initialise actions list. Two thirds of
+ # Initialise actions list. Two thirds of
# rounds will be song_sync; the remainder heard_before.
- next_round_number = session.get_next_round()
# Collect actions.
actions = []
- if next_round_number == 1:
+ if round_number == 0:
# get list of trials for demographic questions (first 2 questions)
questions = self.get_questionnaire(session, cutoff_index=2)
if questions:
@@ -124,43 +119,38 @@ def next_round(self, session):
)
# Go to SongSync
- elif next_round_number == 2:
+ elif round_number == 1:
decades = session.result_set.first().given_response.split(',')
self.plan_sections(session, {'group__in': decades})
- actions.append(self.next_song_sync_action(session))
+ actions.extend(self.next_song_sync_action(session))
else:
# Create a score action.
- config = {'show_section': True, 'show_total_score': True}
- title = self.get_trial_title(session, next_round_number - 1 - self.round_modifier)
- actions.append(Score(session,
- config=config,
- title=title
- ))
+ actions.append(self.get_score(session, round_number - self.round_modifier))
# Load the heard_before offset.
plan = json_data.get('plan')
- heard_before_offset = len(plan['song_sync_sections']) + 2
+ heard_before_offset = len(plan['song_sync_sections'])
# SongSync rounds. Skip questions until Round 5.
- if next_round_number in range(3, 6):
- actions.append(self.next_song_sync_action(session))
- if next_round_number in range(6, heard_before_offset):
+ if round_number in range(2, 5):
+ actions.extend(self.next_song_sync_action(session))
+ if round_number in range(5, heard_before_offset):
question = self.get_single_question(session, randomize=True)
if question:
actions.append(question)
- actions.append(self.next_song_sync_action(session))
+ actions.extend(self.next_song_sync_action(session))
# HeardBefore rounds
- if next_round_number == heard_before_offset:
+ if round_number == heard_before_offset:
# Introduce new round type with Explainer.
actions.append(self.heard_before_explainer())
actions.append(
self.next_heard_before_action(session))
- if next_round_number > heard_before_offset:
+ if round_number > heard_before_offset:
+ actions.append(
+ self.next_heard_before_action(session))
question = self.get_single_question(session, randomize=True)
if question:
actions.append(question)
- actions.append(
- self.next_heard_before_action(session))
return actions
\ No newline at end of file
diff --git a/backend/experiment/rules/toontjehoger_3_plink.py b/backend/experiment/rules/toontjehoger_3_plink.py
index b50b3ecd5..e07a89d03 100644
--- a/backend/experiment/rules/toontjehoger_3_plink.py
+++ b/backend/experiment/rules/toontjehoger_3_plink.py
@@ -3,8 +3,8 @@
from django.template.loader import render_to_string
from .toontjehoger_1_mozart import toontjehoger_ranks
-from experiment.actions import Plink, Explainer, Step, Score, Final, StartSession, Playlist, Info
-from experiment.actions.form import RadiosQuestion
+from experiment.actions import Explainer, Step, Score, Final, StartSession, Playback, Playlist, Info, Trial
+from experiment.actions.form import AutoCompleteQuestion, RadiosQuestion, Form
from .base import Base
from experiment.utils import non_breaking_spaces
@@ -55,98 +55,85 @@ def first_round(self, experiment):
def next_round(self, session, request_session=None):
"""Get action data for the next round"""
- rounds_passed = session.rounds_passed()
+ rounds_passed = session.get_relevant_results(['plink']).count()
# Round 1
if rounds_passed == 0:
- # No combine_actions because of inconsistent next_round array wrapping in first round
return self.get_plink_round(session)
# Round 2-experiments.rounds
if rounds_passed < session.experiment.rounds:
- return [*self.get_score(session), *self.get_plink_round(session)]
+ return self.get_plink_round(session, present_score=True)
# Final
return self.get_final_round(session)
-
- def get_score_message(self, session):
- last_result = session.last_result()
-
- if not last_result:
- logger.error("No last result")
- return ""
-
- data = last_result.load_json_data()
- # Section
- section = last_result.section
- if not section:
- logger.error("Result without section")
- return ""
+ def get_last_results(self, session):
+ ''' get the last score, based on either the main question (plink)
+ (only if not skipped)
+ or the previous two questions (era and emotion)
+ '''
+ last_results = session.result_set.order_by('-created_at')[:3]
- # Option 1. Main question
- main_question = Plink.extract_main_question(data)
-
- if main_question:
- if main_question == last_result.expected_response:
- return "Goedzo! Je hoorde inderdaad {} van {}.".format(non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist))
-
- return "Helaas! Je hoorde {} van {}.".format(non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist))
-
- # Option 2. Extra questions
- extra_questions = Plink.extract_extra_questions(data)
-
- # No extra questions? Return just an empty string
- if not extra_questions:
+ if not last_results:
+ logger.error("No last result")
return ""
-
- # Feedback prefix
-
- # - All points
- feedback_prefix = "Goedzo!"
-
- # - Partial score or all questions wrong
- all_wrong_score = last_result.score == 2 * self.SCORE_EXTRA_WRONG
- only_half_score = last_result.score < self.SCORE_EXTRA_1_CORRECT + \
- self.SCORE_EXTRA_2_CORRECT if not all_wrong_score else False
-
- if all_wrong_score:
- feedback_prefix = "Helaas!"
- elif only_half_score:
- feedback_prefix = "Deels goed!"
-
- # Get section info
- section_details = section.group.split(";")
- time_period = section_details[0] if len(
- section_details) >= 1 else "?"
- time_period = time_period.replace("s", "'s")
- emotion = section_details[1] if len(section_details) >= 2 else "?"
-
- # Construct final feedback message
- question_part = "Het nummer komt uit de {} en de emotie is {}.".format(
- time_period, emotion)
- section_part = "Je hoorde {} van {}.".format(
- non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist))
-
- # The \n results in a linebreak
- feedback = "{} {} \n {}".format(
- feedback_prefix, question_part, section_part)
- return feedback
-
- def get_score(self, session):
- feedback = self.get_score_message(session)
-
- # Return score view
+
+ if last_results[2].given_response != '':
+ # delete other results, because these questions weren't asked
+ last_results[0].delete()
+ last_results[1].delete()
+ return [last_results[2]]
+
+ return last_results[:2]
+
+ def get_score_view(self, session):
+ last_results = self.get_last_results(session)
+ section = last_results[0].section
+ score = sum([r.score for r in last_results])
+
+ if len(last_results) == 1:
+ # plink result
+ if last_results[0].expected_response == last_results[0].given_response:
+ feedback = "Goedzo! Je hoorde inderdaad {} van {}.".format(non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist))
+ else:
+ feedback = "Helaas! Je hoorde {} van {}.".format(non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist))
+ else:
+ if score == 2 * self.SCORE_EXTRA_WRONG:
+ feedback_prefix = "Helaas!"
+ elif score == self.SCORE_EXTRA_1_CORRECT + self.SCORE_EXTRA_2_CORRECT:
+ feedback_prefix = "Goedzo!"
+ else:
+ feedback_prefix = "Deels goed!"
+
+ # Get section info
+ section_details = section.group.split(";")
+ time_period = section_details[0] if len(
+ section_details) >= 1 else "?"
+ time_period = time_period.replace("s", "'s")
+ emotion = section_details[1] if len(section_details) >= 2 else "?"
+
+ # Construct final feedback message
+ question_part = "Het nummer komt uit de {} en de emotie is {}.".format(
+ time_period, emotion)
+ section_part = "Je hoorde {} van {}.".format(
+ non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist))
+
+ # The \n results in a linebreak
+ feedback = "{} {} \n {}".format(
+ feedback_prefix, question_part, section_part)
+
config = {'show_total_score': True}
- score = Score(session, config=config, feedback=feedback)
-
- return [score]
+ round_number = session.get_relevant_results(['plink']).count() - 1
+ score_title = "Ronde %(number)d / %(total)d" %\
+ {'number': round_number+1, 'total': session.experiment.rounds}
+ return Score(session, config=config, feedback=feedback, score=score, title=score_title)
+
- def get_plink_round(self, session):
-
- # Config
- # -----------------
-
+ def get_plink_round(self, session, present_score=False):
+ next_round = []
+ if present_score:
+ next_round.append(self.get_score_view(session))
# Get all song sections
all_sections = session.all_sections()
choices = {}
@@ -160,41 +147,55 @@ def get_plink_round(self, session):
raise Exception("Error: could not find section")
expected_response = section.pk
-
- # Extra questions intro
- # --------------------
- extra_questions_intro = Explainer(
- instruction="Tussenronde",
- steps=[
- Step("Jammer dat je de artiest en titel van dit nummer niet weet!"),
- Step(
- "Verdien extra punten door twee extra vragen over het nummer te beantwoorden."),
- ],
- button_label="Start"
- )
-
- # Plink round
- # --------------------
- extra_questions = [self.get_optional_question1(
- session), self.get_optional_question2(session)]
-
- plink = Plink(
- section=section,
- title=self.TITLE,
- result_id=prepare_result(
- 'plink', session, section=section, expected_response=expected_response
- ),
- main_question="Noem de artiest en de titel van het nummer",
+
+ question1 = AutoCompleteQuestion(
+ key='plink',
choices=choices,
- submit_label="Volgende",
- dont_know_label="Ik weet het niet",
- extra_questions=extra_questions,
- extra_questions_intro=extra_questions_intro
+ question='Noem de artiest en de titel van het nummer',
+ result_id=prepare_result(
+ 'plink',
+ session,
+ section=section,
+ expected_response=expected_response
+ )
)
+ next_round.append(Trial(
+ playback=Playback(
+ player_type='BUTTON',
+ sections=[section]
+ ),
+ feedback_form=Form(
+ [question1],
+ is_skippable=True,
+ skip_label='Ik weet het niet',
+ submit_label='Volgende'
+ ),
+ config={'break_round_on': {'NOT': ['']}}
+ ))
+ json_data = session.load_json_data()
+ if not json_data.get('extra_questions_intro_shown'):
+ # Extra questions intro: only show first time
+ # --------------------
+ extra_questions_intro = Explainer(
+ instruction="Tussenronde",
+ steps=[
+ Step("Jammer dat je de artiest en titel van dit nummer niet weet!"),
+ Step(
+ "Verdien extra punten door twee extra vragen over het nummer te beantwoorden."),
+ ],
+ button_label="Start"
+ )
+ next_round.append(extra_questions_intro)
+
+ extra_rounds = [
+ self.get_era_question(session, section),
+ self.get_emotion_question(session, section)
+ ]
+
- return [plink]
+ return [*next_round, *extra_rounds]
- def get_optional_question1(self, session):
+ def get_era_question(self, session, section):
# Config
# -----------------
@@ -209,12 +210,17 @@ def get_optional_question1(self, session):
question="Wanneer is het nummer uitgebracht?",
key='time_period',
choices=period_choices,
- submits=False
+ result_id=prepare_result(
+ 'era',
+ session,
+ section=section,
+ expected_response=section.group.split(';')[0]
+ )
)
- return question
+ return Trial(feedback_form=Form([question]))
- def get_optional_question2(self, session):
+ def get_emotion_question(self, session, section):
# Question
emotions = ['vrolijk', 'droevig', 'boosheid', 'angst', 'tederheid']
@@ -226,58 +232,28 @@ def get_optional_question2(self, session):
question="Welke emotie past bij dit nummer?",
key='emotion',
choices=emotion_choices,
- submits=True
+ result_id=prepare_result(
+ 'emotion',
+ session,
+ section=section,
+ expected_response=section.group.split(';')[1]
+ )
)
- return question
+ return Trial(feedback_form=Form([question]))
def calculate_score(self, result, data):
"""
Calculate score, based on the data field
-
- e.g. only main question answered
- {
- main_question: "100",
- extra_questions: []
- }
-
- e.g. only main question answered
- {
- main_question: "",
- extra_questions: ["60s","vrolijk"]
- }
-
"""
- main_question = Plink.extract_main_question(data)
-
- # Participant guessed the artist/title:
- if main_question != "":
- result.given_response = main_question
- result.save()
+ if result.question_key == 'plink':
return self.SCORE_MAIN_CORRECT if result.expected_response == result.given_response else self.SCORE_MAIN_WRONG
-
- # Handle extra questions data
- extra_questions = Plink.extract_extra_questions(data)
- if extra_questions:
- section = result.section
- if section is None:
- logger.error("Error: No section on result")
- return 0
-
- score = 0
-
- # Check if the given answers
- # e.g section.group = 60s;vrolijk (time_period;emotion)
- for index, answer in enumerate(extra_questions):
- points_correct = self.SCORE_EXTRA_1_CORRECT if index == 0 else self.SCORE_EXTRA_2_CORRECT
- score += points_correct if answer and (
- answer in section.group) else self.SCORE_EXTRA_WRONG
-
- return score
-
- # Should not happen
- logger.error("Error: could not calculate score")
- return 0
+ elif result.question_key == 'era':
+ result.session.save_json_data({'extra_questions_intro_shown': True})
+ result.session.save()
+ return self.SCORE_EXTRA_1_CORRECT if result.given_response == result.expected_response else self.SCORE_EXTRA_WRONG
+ else:
+ return self.SCORE_EXTRA_2_CORRECT if result.given_response == result.expected_response else self.SCORE_EXTRA_WRONG
def get_final_round(self, session):
@@ -286,7 +262,7 @@ def get_final_round(self, session):
session.save()
# Score
- score = self.get_score(session)
+ score = self.get_score_view(session)
# Final
final_text = "Goed gedaan, jouw muziekherkenning is uitstekend!" if session.final_score >= 4 * \
@@ -308,4 +284,4 @@ def get_final_round(self, session):
button_link="/toontjehoger"
)
- return [*score, final, info]
+ return [score, final, info]
diff --git a/backend/result/score.py b/backend/result/score.py
index 3d6b02e43..35ae1ce1f 100644
--- a/backend/result/score.py
+++ b/backend/result/score.py
@@ -1,6 +1,8 @@
import logging
import math
+from django.db.models import Q
+
logger = logging.getLogger(__name__)
def check_expected_response(result):
@@ -44,30 +46,21 @@ def reaction_time_score(result, data):
else:
return math.floor(-time)
-def song_sync_score(result, data):
- score = 0
- # Calculate from the data object
- # If requested keys don't exist, return None
- try:
- config = data['config']
- result = data['result']
- # Calculate scores based on result type
- if result['type'] == 'time_passed':
- score = 0
- elif result['type'] == 'not_recognized':
- score = 0
- elif result['type'] == 'recognized':
- # Get score
- score = math.ceil(
- config['recognition_time'] - result['recognition_time']
- )
- if config['continuation_correctness'] != result['continuation_correctness']:
- score *= -1
- except KeyError as error:
- logger.warning('KeyError: %s' % str(error))
- return None
- return score
-
+def song_sync_recognition_score(result, data):
+ if result.given_response == 'TIMEOUT' or result.given_response == 'no':
+ return 0
+ json_data = result.load_json_data()
+ if json_data:
+ time = json_data.get('decision_time')
+ timeout = json_data.get('config').get('response_time')
+ return math.ceil(timeout - time)
+
+def song_sync_continuation_score(result, data):
+ previous_result = result.session.get_previous_result(['recognize'])
+ if check_expected_response(result) != result.given_response:
+ previous_result.score *= -1
+ previous_result.save()
+ return None
SCORING_RULES = {
'BOOLEAN': boolean_score,
@@ -75,6 +68,7 @@ def song_sync_score(result, data):
'LIKERT': likert_score,
'REVERSE_LIKERT': reverse_likert_score,
'REACTION_TIME': reaction_time_score,
- 'SONG_SYNC': song_sync_score,
+ 'SONG_SYNC_RECOGNITION': song_sync_recognition_score,
+ 'SONG_SYNC_CONTINUATION': song_sync_continuation_score,
'CATEGORIES_TO_LIKERT': categories_likert_score,
-}
\ No newline at end of file
+}
diff --git a/backend/result/tests/test_views.py b/backend/result/tests/test_views.py
index d928eef09..eab5ca62f 100644
--- a/backend/result/tests/test_views.py
+++ b/backend/result/tests/test_views.py
@@ -163,25 +163,40 @@ def correctness_request(self, value):
]}
return self.make_request(view)
- def song_sync_request(self, result_type, continuation_correctness):
+ def song_sync_recognize_request(self, result_type):
result = Result.objects.create(
- session = self.session,
- section = self.section,
- scoring_rule='SONG_SYNC'
+ question_key='recognize',
+ session=self.session,
+ section=self.section,
+ scoring_rule='SONG_SYNC_RECOGNITION'
)
view = {
- "result_id": result.pk,
- "view": "SONG_SYNC",
- "key": "song_sync",
- "result": {
- "type": result_type,
- "continuation_correctness": continuation_correctness,
- "recognition_time": 10
+ "decision_time": 10,
+ "config": {
+ "response_time": 15
},
+ "form": [
+ {"key": "recognize", "result_id": result.pk, "value": result_type}
+ ]
+ }
+ return self.make_request(view)
+
+ def song_sync_continue_request(self, result_type):
+ result = Result.objects.create(
+ question_key='correct_place',
+ session=self.session,
+ section=self.section,
+ scoring_rule='SONG_SYNC_CONTINUATION',
+ expected_response='yes'
+ )
+ view = {
+ "decision_time": 10,
"config": {
- "recognition_time": 15,
- "continuation_correctness": True,
- }
+ "response_time": 15
+ },
+ "form": [
+ {"key": "recognize", "result_id": result.pk, "value": result_type}
+ ]
}
return self.make_request(view)
@@ -236,19 +251,20 @@ def test_correctness(self):
assert self.session.result_set.last().score == 0
def test_song_sync(self):
- client_request = self.song_sync_request('time_passed', False)
+ client_request = self.song_sync_recognize_request("TIMEOUT")
response = self.client.post('/result/score/', client_request)
assert response.status_code == 200
assert self.session.result_set.last().score == 0
- client_request = self.song_sync_request('not_recognized', False)
+ client_request = self.song_sync_recognize_request('no')
response = self.client.post('/result/score/', client_request)
assert response.status_code == 200
assert self.session.result_set.last().score == 0
- client_request = self.song_sync_request('recognized', False)
+ client_request = self.song_sync_recognize_request('yes')
response = self.client.post('/result/score/', client_request)
assert response.status_code == 200
- assert self.session.result_set.last().score == -5
- client_request = self.song_sync_request('recognized', True)
+ assert self.session.result_set.last().score == 5
+ client_request = self.song_sync_continue_request('no')
response = self.client.post('/result/score/', client_request)
assert response.status_code == 200
- assert self.session.result_set.last().score == 5
+ assert self.session.get_previous_result(['recognize']).score == -5
+
diff --git a/backend/result/utils.py b/backend/result/utils.py
index fc519aaf6..f95760c6a 100644
--- a/backend/result/utils.py
+++ b/backend/result/utils.py
@@ -1,7 +1,7 @@
from .models import Result
-from .score import SCORING_RULES
from experiment.questions.profile_scoring_rules import PROFILE_SCORING_RULES
+from result.score import SCORING_RULES
def get_result(session, data):
result_id = data.get('result_id')
@@ -82,18 +82,20 @@ def score_result(data, session):
}
"""
result = get_result(session, data)
+ result.save_json_data(data)
result.given_response = data.get('value')
# Calculate score: by default, apply a scoring rule
# Can be overridden by defining calculate_score in the rules file
if result.session:
score = session.experiment_rules().calculate_score(result, data)
+ # refresh session data in case anything was changed within calculate_score function
+ session.refresh_from_db()
else:
# this is a profile type result, i.e., it doesn't have a session:
score = apply_scoring_rule(result, data)
# Populate and save the result
# result can also be None
result.score = score
- result.save_json_data(data)
result.save()
return result
diff --git a/backend/result/views.py b/backend/result/views.py
index d87d73af7..3c130f258 100644
--- a/backend/result/views.py
+++ b/backend/result/views.py
@@ -36,7 +36,7 @@ def score(request):
try:
result_data = json.loads(json_data)
# Create a result from the data
- result = handle_results(result_data, session)
+ result = handle_results(result_data, session)
if not result:
return HttpResponseServerError("Could not create result from data")
if result.session:
diff --git a/backend/section/models.py b/backend/section/models.py
index 813182a68..8908c50b3 100644
--- a/backend/section/models.py
+++ b/backend/section/models.py
@@ -166,13 +166,20 @@ def song_ids(self, filter_by={}):
# order_by is required to make distinct work with values_list
return self.section_set.filter(**filter_by).order_by('song').values_list('song_id', flat=True).distinct()
- def random_section(self, filter_by={}):
- """Get a random section from this playlist"""
- pks = self.section_set.filter(**filter_by).values_list('pk', flat=True)
+ def get_section(self, filter_by={}, song_ids=[]):
+ """Get a random section from this playlist
+ Optionally, limit to specific song_ids and filter conditions
+ """
+ if song_ids:
+ sections = self.section_set.filter(song__id__in=song_ids)
+ else:
+ sections = self.section_set
+ pks = sections.filter(**filter_by).values_list('pk', flat=True)
if len(pks) == 0:
return None
return self.section_set.get(pk=random.choice(pks))
+
def export_admin(self):
"""Export data for admin"""
return {
diff --git a/backend/section/tests.py b/backend/section/tests.py
index 45eace9df..8b9887572 100644
--- a/backend/section/tests.py
+++ b/backend/section/tests.py
@@ -36,6 +36,25 @@ def test_update_sections_not_number(self):
"Salvador Sobral,Amar pelos dois,0.0,10.0,bat/sobral.mp3,0,0,0\n")
s = playlist.update_sections()
self.assertEqual(s['status'], playlist.CSV_ERROR)
+
+ def test_get_section(self):
+ playlist = Playlist.objects.get(name='TestPlaylist')
+ playlist.csv = (
+ "Weird Al,Eat It,0.0,10.0,some/file.mp3,0,tag1,0\n"
+ "Weird Al,Eat It,10.0,20.0,some/file.mp3,0,tag2,0\n"
+ "Weird Al,Like a Surgeon,0.0,10.0,some/otherfile.mp3,0,tag1,0\n"
+ "Weird Al,Like a Surgeon,10.0,20.0,some/otherfile.mp3,0,tag2,0\n"
+ )
+ playlist.update_sections()
+ assert Song.objects.count() == 2
+ song1 = Song.objects.get(name='Eat It')
+ section = playlist.get_section(song_ids=[song1.id])
+ assert section.song.id == song1.id
+ section = playlist.get_section(filter_by={'tag': 'tag1'})
+ assert section.tag == 'tag1'
+ song2 = Song.objects.get(name='Like a Surgeon')
+ section = playlist.get_section(filter_by={'tag': 'tag2'}, song_ids=[song2.id])
+ assert section.tag == 'tag2' and section.song.id == song2.id
def test_valid_csv(self):
playlist = Playlist.objects.get(name='TestPlaylist')
@@ -76,12 +95,12 @@ class TestAmdinEditSection(TestCase):
@classmethod
def setUpTestData(cls):
- Playlist.objects.create()
- Song.objects.create(artist='default',
+ cls.playlist = Playlist.objects.create()
+ cls.song = Song.objects.create(artist='default',
name='default',
- restricted = [{"restricted": "nl"}])
- Section.objects.create(playlist=Playlist.objects.first(),
- song = Song.objects.first())
+ restricted= [{"restricted": "nl"}])
+ Section.objects.create(playlist=cls.playlist,
+ song=cls.song)
def test_edit_sections(self):
request = MockRequest()
@@ -106,3 +125,4 @@ def test_edit_sections(self):
self.assertEqual(edit_section.group, 'edited')
self.assertEqual(edit_section.song.restricted, [])
self.assertEqual(response.status_code, 302)
+
\ No newline at end of file
diff --git a/backend/session/models.py b/backend/session/models.py
index 5816f32bf..c8f5b3f60 100644
--- a/backend/session/models.py
+++ b/backend/session/models.py
@@ -49,7 +49,6 @@ def last_song(self):
"""Return artist and name of previous song,
or return empty string if no scores are set
"""
-
section = self.previous_section()
if section:
return "{} - {}".format(section.song.artist, section.song.name)
@@ -243,3 +242,13 @@ def skipped_questions(self):
def answered_questions(self):
"""Get number of answered (non-empty) profile questions for this session"""
return self.result_set.exclude(given_response="").count()
+
+ def get_relevant_results(self, question_keys=[]):
+ results = self.result_set
+ if question_keys:
+ return results.filter(question_key__in=question_keys)
+ return results
+
+ def get_previous_result(self, question_keys=[]):
+ results = self.get_relevant_results(question_keys)
+ return results.order_by('-created_at').first()
diff --git a/backend/session/tests/__init__.py b/backend/session/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/session/tests.py b/backend/session/tests/test_session.py
similarity index 85%
rename from backend/session/tests.py
rename to backend/session/tests/test_session.py
index 58332dbd6..d5493d4d3 100644
--- a/backend/session/tests.py
+++ b/backend/session/tests/test_session.py
@@ -5,7 +5,7 @@
from experiment.models import Experiment
from participant.models import Participant
-from section.models import Playlist
+from section.models import Playlist, Section, Song
from result.models import Result
from session.models import Session
@@ -98,3 +98,18 @@ def test_percentile_rank(self):
rank = finished_session.percentile_rank(exclude_unfinished=False)
assert rank == 62.5
+ def test_last_song(self):
+ song = Song.objects.create(artist='Beavis', name='Butthead')
+ section = Section.objects.create(playlist=self.playlist, song=song)
+ Result.objects.create(
+ session=self.session,
+ section=section,
+ question_key='preference',
+ score=0
+ )
+ previous_section = self.session.previous_section()
+ assert previous_section
+ last_song = self.session.last_song()
+ assert last_song == 'Beavis - Butthead'
+
+
diff --git a/backend/session/tests/test_utils.py b/backend/session/tests/test_utils.py
new file mode 100644
index 000000000..4a992f51a
--- /dev/null
+++ b/backend/session/tests/test_utils.py
@@ -0,0 +1,43 @@
+
+from django.test import TestCase
+
+from experiment.models import Experiment
+from participant.models import Participant
+from result.models import Result
+from session.models import Session
+
+
+n_results = 10
+
+class SessionUtilsTest(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.participant = Participant.objects.create(unique_hash=42)
+ cls.experiment = Experiment.objects.create(
+ rules='MUSICAL_PREFERENCES', slug='test')
+ cls.session = Session.objects.create(
+ experiment=cls.experiment,
+ participant=cls.participant,
+ )
+ # create results with various question_keys, and scores from 0 to 9
+ for i in range(n_results):
+ keys = ['a', 'a', 'b', 'b', 'b', 'b', 'c', 'c', 'c', 'd']
+ Result.objects.create(
+ session=cls.session,
+ question_key=keys[i],
+ score=i
+ )
+
+ def test_relevant_results_without_filter(self):
+ results = self.session.get_relevant_results()
+ assert results.count() == n_results
+
+ def test_relevant_results_with_filter(self):
+ results = self.session.get_relevant_results(['a', 'b'])
+ assert results.count() == 6
+ assert 'd' not in results.values_list('question_key')
+
+ def test_previous_score(self):
+ result = self.session.get_previous_result(['c', 'd'])
+ assert result.score == 9
+
diff --git a/backend/session/views.py b/backend/session/views.py
index a6651e978..20ce36758 100644
--- a/backend/session/views.py
+++ b/backend/session/views.py
@@ -53,8 +53,7 @@ def create_session(request):
data = request.POST.get("data")
if data:
try:
- json.loads(data)
- session.json_data = data
+ session.save_json_data(data)
except ValueError:
return HttpResponseBadRequest("Invalid data")
diff --git a/frontend/src/components/Experiment/Experiment.js b/frontend/src/components/Experiment/Experiment.js
index 3d5e857af..89b6e0351 100644
--- a/frontend/src/components/Experiment/Experiment.js
+++ b/frontend/src/components/Experiment/Experiment.js
@@ -11,8 +11,6 @@ import Final from "../Final/Final";
import Loading from "../Loading/Loading";
import Playlist from "../Playlist/Playlist";
import Score from "../Score/Score";
-import SongSync from "../SongSync/SongSync";
-import Plink from "../Plink/Plink";
import StartSession from "../StartSession/StartSession";
import Trial from "../Trial/Trial";
import useResultHandler from "../../hooks/useResultHandler";
@@ -93,8 +91,8 @@ const Experiment = ({ match, location }) => {
]);
// trigger next action from next_round array, or call session/next_round
- const onNext = async () => {
- if (actions.length) {
+ const onNext = async (doBreak) => {
+ if (!doBreak && actions.length) {
updateActions(actions);
} else {
// Try to get next_round data from server
@@ -143,10 +141,6 @@ const Experiment = ({ match, location }) => {
// -------------------------
case "TRIAL_VIEW":
return ;
- case "SONG_SYNC":
- return ;
- case "PLINK":
- return ;
// Information & Scoring
// -------------------------
diff --git a/frontend/src/components/FeedbackForm/FeedbackForm.js b/frontend/src/components/FeedbackForm/FeedbackForm.js
index 1bf0f06fc..0d853cc8e 100644
--- a/frontend/src/components/FeedbackForm/FeedbackForm.js
+++ b/frontend/src/components/FeedbackForm/FeedbackForm.js
@@ -1,4 +1,5 @@
import React, { useState, useRef } from "react";
+
import Question from "../Question/Question";
import Button from "../Button/Button";
@@ -67,34 +68,32 @@ const FeedbackForm = ({
/>
))}
{/* Continue button */}
- {showSubmitButtons && formValid && (
-
-
- )}
-
- {/* Skip button */}
- {/* Only show skip-button when there is no value */}
- {isSkippable && showSubmitButtons && (
-
+ title={skipLabel}
+ />)}
{
onSubmit();
}}
className={
- "btn-gray anim anim-fade-in anim-speed-500"
+ "submit col-4"
}
- title={skipLabel}
+ active={formValid}
+ title={buttonLabel}
/>
+
+
)}
diff --git a/frontend/src/components/Playback/Autoplay.js b/frontend/src/components/Playback/Autoplay.js
index 694b1e435..e6898c54c 100644
--- a/frontend/src/components/Playback/Autoplay.js
+++ b/frontend/src/components/Playback/Autoplay.js
@@ -1,11 +1,11 @@
-import React, { useRef, useState, useEffect } from "react";
+import React, { useRef, useEffect } from "react";
import { playAudio } from "../../util/audioControl";
import Circle from "../Circle/Circle";
import ListenCircle from "../ListenCircle/ListenCircle";
-const AutoPlay = ({instruction, preloadMessage, onPreloadReady, playConfig, sections, time, startedPlaying, finishedPlaying, responseTime, className=''}) => {
+const AutoPlay = ({instruction, playConfig, sections, time, startedPlaying, finishedPlaying, responseTime, className=''}) => {
// player state
const running = useRef(playConfig.auto_play);
diff --git a/frontend/src/components/Plink/Plink.js b/frontend/src/components/Plink/Plink.js
deleted file mode 100644
index 20dfec1b0..000000000
--- a/frontend/src/components/Plink/Plink.js
+++ /dev/null
@@ -1,191 +0,0 @@
-import React, { useState, useRef, useCallback } from "react";
-import Playback, { BUTTON } from "../Playback/Playback";
-import Question, { AUTOCOMPLETE, DROPDOWN } from "../Question/Question";
-import Explainer from "../Explainer/Explainer";
-import Button from "../Button/Button";
-import useSingleToArray from "../../hooks/useSingleToArray";
-
-const RECOGNIZE = "MAIN";
-const QUESTION_INTRO = "QUESTION_INTRO";
-const QUESTION = "QUESTION";
-
-const defaultPlayConfig = {
- auto_play: true,
-};
-
-// Plink is an experiment view, with two stages:
-// - RECOGNIZE: Play audio, ask if participant recognizes the song
-// - QUESTION_INTRO: A short introduction to the extra questions, shown once
-// - QUESTION: Optional questions when then participant doesnt know the song
-const Plink = ({
- section,
- mainQuestion,
- choices,
- submitLabel,
- dontKnowLabel,
- extraQuestions,
- extraQuestionsIntro,
- result_id,
- onResult,
-}) => {
- // Component view
- const [view, setView] = useState(RECOGNIZE);
-
- // Sections array required for Playback component
- const sections = useSingleToArray(section);
-
- // Question data storage
- const [mainQuestionValue, setMainQuestionValue] = useState("");
- const extraQuestionValues = useRef([]);
-
- // Submit data
- const submitData = useCallback(() => {
- onResult(
- {
- result_id: result_id,
- config: {},
- main_question: mainQuestionValue,
- extra_questions: extraQuestionValues.current,
- },
- true
- );
- }, [mainQuestionValue, result_id, onResult]);
-
- // extra Questions mode
- // =================================
- const [questionIndex, setQuestionIndex] = useState(0);
-
- // Start the questions mode
- const startQuestions = useCallback(() => {
- // If there are not questions, just submit
- if (extraQuestions.length === 0) {
- submitData();
- return;
- }
-
- // Optionally show the extra question intro text
- if (showExtraQuestionsIntroOnce()){
- setView(QUESTION_INTRO);
- return;
- }
-
- // Show questions
- setMainQuestionValue("");
- setView(QUESTION);
- }, [submitData, extraQuestions]);
-
- // Load next question, or submit if there are no more questions
- const nextQuestion = () => {
- // Add value
- extraQuestionValues.current.push(questionValue);
-
- // Clear
- setQuestionValue("");
-
- // Next
- const nextIndex = questionIndex + 1;
-
- // No more questions, submit data
- if (nextIndex >= extraQuestions.length) {
- submitData();
- return;
- }
-
- // Go to next
- setQuestionIndex(nextIndex);
- };
-
- // Keep track of current extra question value
- const [questionValue, setQuestionValue] = useState("");
-
- // Render component based on view
- const getView = (view) => {
- switch (view) {
- case RECOGNIZE:
- return (
-
-
-
-
- {/* Don't know */}
-
- {/* Submit main question answer */}
-
-
-
- );
- case QUESTION_INTRO:
- return (
-
- )
- case QUESTION:
- return (
-
-
-
500 ? extraQuestions[questionIndex] : Object.assign({}, extraQuestions[questionIndex], {"view": DROPDOWN})}
- onChange={setQuestionValue}
- id="sub"
- />
-
-
- );
- default:
- return Unknown view: {view}
;
- }
- };
-
- return {getView(view)}
;
-};
-
-
-// Retrieve if the extra questions intro should be shown
-// Also set the value so the intro only shows one time per session
-const showExtraQuestionsIntroOnce = ()=>{
- const storageKey = 'aml_toontjehoger_plink_extra_question_intro'
- const doShow = null === window.sessionStorage.getItem(storageKey);
-
- if (doShow){
- window.sessionStorage.setItem(storageKey, 'shown')
- }
-
- return doShow;
-}
-
-export default Plink;
diff --git a/frontend/src/components/Plink/Plink.scss b/frontend/src/components/Plink/Plink.scss
deleted file mode 100644
index b1f043350..000000000
--- a/frontend/src/components/Plink/Plink.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-.aha__plink{
- .aha__play-button{
- @include btn-style($blue);
- }
-
- .plink-extra-questions .aha__question{
- animation-name: plink-question-intro;
- animation-delay: 0.05s;
- animation-duration: 0.4s;
- animation-iteration-count: 1;
- animation-fill-mode: both;
- }
-}
-
-@keyframes plink-question-intro {
- from{
- opacity: 0;
- }
-
- to{
- opacity: 1;
- }
-}
\ No newline at end of file
diff --git a/frontend/src/components/Preload/Preload.js b/frontend/src/components/Preload/Preload.js
index 14453c554..7a28ee7d9 100644
--- a/frontend/src/components/Preload/Preload.js
+++ b/frontend/src/components/Preload/Preload.js
@@ -29,13 +29,18 @@ const Preload = ({ instruction, pageTitle, duration, sections, playConfig, onNex
// Use Web-audio and preload sections in buffers
sections.map((section, index) => {
+ // skip Preload if the section has already been loaded in the previous action
+ if (webAudio.checkSectionLoaded(section)) {
+ onNext();
+ return undefined;
+ }
// Clear buffers if this is the first section
if (index === 0) {
webAudio.clearBuffers();
}
// Load sections in buffer
- return webAudio.loadBuffer(section.id, MEDIA_ROOT + section.url, () => {
+ return webAudio.loadBuffer(section.id, section.url, () => {
if (index === (sections.length - 1)) {
audioIsAvailable.current = true;
if (timeHasPassed.current) {
diff --git a/frontend/src/components/Question/Question.js b/frontend/src/components/Question/Question.js
index ae48751bd..512e12fa9 100644
--- a/frontend/src/components/Question/Question.js
+++ b/frontend/src/components/Question/Question.js
@@ -28,7 +28,6 @@ const Question = ({
onChange,
id,
active,
- style,
emphasizeTitle = false,
}) => {
const [value, setValue] = useState(question.value || "");
@@ -44,7 +43,7 @@ const Question = ({
value,
question,
active,
- style,
+ style: question.style,
emphasizeTitle,
onChange: registerChange,
};
diff --git a/frontend/src/components/Question/_ButtonArray.scss b/frontend/src/components/Question/_ButtonArray.scss
index fe57753af..f6374c085 100644
--- a/frontend/src/components/Question/_ButtonArray.scss
+++ b/frontend/src/components/Question/_ButtonArray.scss
@@ -14,7 +14,20 @@
.btn-group-toggle-custom{
display: flex;
- column-gap: 20px;
+ column-gap: 3rem;
+
+ &.buttons-large-gap {
+ column-gap: 5rem;
+ }
+
+ &.buttons-large-text {
+ font-size: 1.3rem;
+ }
+
+ .btn {
+ min-width: 4rem;
+ }
+
input{
display: none;
@@ -26,54 +39,4 @@
}
}
}
-
- .buttons-large-gap {
- column-gap: 5rem;
- font-size: 1.3rem;
- }
-}
-
-.boolean {
- .btn-secondary:nth-of-type(1) {
- @include btn-style($teal);
- }
- .btn-secondary:nth-of-type(2) {
- @include btn-style($yellow);
- }
- .btn-secondary:last-of-type {
- @include btn-style($red);
- }
}
-
-.boolean-negative-first {
- .btn-secondary:nth-of-type(1) {
- @include btn-style($red);
- }
- .btn-secondary:nth-of-type(2) {
- @include btn-style($yellow);
- }
- .btn-secondary:last-of-type {
- @include btn-style($teal);
- }
-}
-
-.neutral {
- .btn:nth-of-type(1) {
- @include btn-style($blue);
- &.submit {
- @include btn-style($pink);
- }
- }
- .btn:nth-of-type(2) {
- @include btn-style($yellow);
- }
-}
-
-.neutral-inverted {
- .btn:nth-of-type(1) {
- @include btn-style($yellow);
- }
- .btn:nth-of-type(2) {
- @include btn-style($blue);
- }
-}
\ No newline at end of file
diff --git a/frontend/src/components/Question/_IconRange.js b/frontend/src/components/Question/_IconRange.js
index 0f85f3f81..210cfec6f 100644
--- a/frontend/src/components/Question/_IconRange.js
+++ b/frontend/src/components/Question/_IconRange.js
@@ -2,11 +2,9 @@ import React from "react";
import Slider from "react-rangeslider";
import classNames from "classnames";
-import RangeLimits from "./_RangeLimits";
import RangeTitle from "./_RangeTitle";
-import { renderLabel } from "../../util/label";
-const IconRange = ({ question, value, onChange, emphasizeTitle }) => {
+const IconRange = ({ question, value, style, onChange, emphasizeTitle }) => {
const emptyValue = !value;
const keys = Object.keys(question.choices);
@@ -24,7 +22,7 @@ const IconRange = ({ question, value, onChange, emphasizeTitle }) => {
}
return (
-
+
{renderLabel(question.choices[value], "fa-2x")}
+ {renderLabel(question.choices[value], "fa-2x")}
)
}
diff --git a/frontend/src/components/SongSync/SongSync.js b/frontend/src/components/SongSync/SongSync.js
deleted file mode 100644
index 15ced4201..000000000
--- a/frontend/src/components/SongSync/SongSync.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import React, { useState, useEffect, useRef } from "react";
-import { playAudio, pauseAudio } from "../../util/audioControl";
-import ListenFeedback from "../Listen/ListenFeedback";
-import ListenCircle from "../ListenCircle/ListenCircle";
-import Preload from "../Preload/Preload";
-import { getCurrentTime, getTimeSince } from "../../util/time";
-
-const PRELOAD = "PRELOAD";
-const RECOGNIZE = "RECOGNIZE";
-const SILENCE = "SILENCE";
-const SYNC = "SYNC";
-
-// SongSync is an experiment view, with four stages:
-// - PRELOAD: Preload a song
-// - RECOGNIZE: Play audio, ask if participant recognizes the song
-// - SILENCE: Silence audio
-// - SYNC: Continue audio, ask is position is in sync
-const SongSync = ({
- view,
- section,
- instructions,
- buttons,
- config,
- result_id,
- onResult
-}) => {
- // Main component state
- const [state, setState] = useState({ view: PRELOAD });
- const [running, setRunning] = useState(true);
-
- const setView = (view, data = {}) => {
- setState({ view, ...data });
- };
-
- const submitted = useRef(false);
-
- // Track time
- const startTime = useRef(getCurrentTime());
-
- // Create result data in this wrapper function
- const addResult = (result) => {
- // Prevent multiple submissions
- if (submitted.current) {
- return;
- }
- submitted.current = true;
-
- // Stop audio
- pauseAudio(config);
-
- setRunning(false);
-
- // Result callback
- onResult({
- view,
- config,
- result,
- // Keep result_id in custom data root
- result_id: result_id,
- });
- };
-
- // Handle view logic
- useEffect(() => {
- let latency;
- switch (state.view) {
- case RECOGNIZE:
- // Play audio at start time
- config.playhead = 0
- // Get latency and compensate timing
- latency = playAudio(config, section[0]);
- setTimeout(startTime.current = getCurrentTime(), latency);
- break;
- case SYNC:
- // Play audio from sync start time
- const syncStart = Math.max(
- 0,
- state.result.recognition_time +
- config.silence_time +
- config.continuation_offset
- );
- config.playhead = syncStart
- // Get latency and compensate timing
- latency = playAudio(config, section[0]);
- setTimeout(startTime.current = getCurrentTime(), latency);
- break;
- default:
- // nothing
- }
-
- // Clean up
- return () => {
- pauseAudio(config);
- };
- }, [state, config]);
-
- // Render component based on view
- switch (state.view) {
- case PRELOAD:
- return (
- {
- setView(RECOGNIZE);
- }}
- />
- );
- case RECOGNIZE:
- return (
-
- }
- instruction={instructions.recognize}
- onFinish={() => {
- addResult({
- type: "time_passed",
- recognition_time: config.recognition_time,
- });
- }}
- onNoClick={() => {
- addResult({
- type: "not_recognized",
- recognition_time: getTimeSince(startTime.current),
- });
- }}
- onYesClick={() => {
- setView(SILENCE, {
- result: {
- type: "recognized",
- recognition_time: getTimeSince(
- startTime.current
- ),
- },
- });
- }}
- />
- );
-
- case SILENCE:
- return (
-
- }
- instruction={instructions.imagine}
- onFinish={() => {
- setView(SYNC, { result: state.result });
- }}
- />
- );
-
- case SYNC:
- return (
-
- }
- instruction={instructions.correct}
- onFinish={() => {
- addResult(
- Object.assign({}, state.result, {
- sync_time: config.sync_time,
- // Always the wrong answer!
- continuation_correctness:
- !config.continuation_correctness,
- })
- );
- }}
- onNoClick={() => {
- addResult(
- Object.assign({}, state.result, {
- sync_time: getTimeSince(startTime.current),
- continuation_correctness: false,
- })
- );
- }}
- onYesClick={() => {
- addResult(
- Object.assign({}, state.result, {
- sync_time: getTimeSince(startTime.current),
- continuation_correctness: true,
- })
- );
- }}
- />
- );
- default:
- return Unknown view: {state.view}
;
- }
-};
-
-export default SongSync;
diff --git a/frontend/src/components/SongSync/SongSync.scss b/frontend/src/components/SongSync/SongSync.scss
deleted file mode 100644
index b8c9eb88e..000000000
--- a/frontend/src/components/SongSync/SongSync.scss
+++ /dev/null
@@ -1,2 +0,0 @@
-.aha__song-sync {
-}
diff --git a/frontend/src/components/Trial/Trial.js b/frontend/src/components/Trial/Trial.js
index 98bc580e9..8ddbc3f87 100644
--- a/frontend/src/components/Trial/Trial.js
+++ b/frontend/src/components/Trial/Trial.js
@@ -38,7 +38,7 @@ const Trial = ({
// Create result data
const makeResult = useCallback(
- (result) => {
+ async (result) => {
// Prevent multiple submissions
if (submitted.current) {
return;
@@ -56,11 +56,21 @@ const Trial = ({
if (feedback_form.is_skippable) {
form.map((formElement => (formElement.value = formElement.value || '')))
}
- onResult({
+ await onResult({
decision_time,
form,
config
});
+ if (config.break_round_on) {
+ const values = form.map((formElement) => formElement.value);
+ if (checkBreakRound(values, config.break_round_on)) {
+ // one of the break conditions is met:
+ // onNext will request next_round from server,
+ // and ignore further rounds in the current array
+ onNext(true)
+ }
+
+ }
} else {
if (result_id) {
onResult({
@@ -76,6 +86,18 @@ const Trial = ({
[feedback_form, config, onNext, onResult]
);
+ const checkBreakRound = (values, breakConditions) => {
+ switch(Object.keys(breakConditions)[0]) {
+ case 'EQUALS':
+ return values.some(val => breakConditions['EQUALS'].includes(val));
+ case 'NOT':
+ return !values.some(val => breakConditions['NOT'].includes(val));
+ default:
+ return false;
+ }
+
+ }
+
const finishedPlaying = useCallback(() => {
if (config.auto_advance) {
diff --git a/frontend/src/components/components.scss b/frontend/src/components/components.scss
index fb71a4f2b..0f6017b94 100644
--- a/frontend/src/components/components.scss
+++ b/frontend/src/components/components.scss
@@ -34,10 +34,8 @@
@import "./Playback/Playback";
@import "./Playback/MatchingPairs";
@import "./Playback/SpectrogramPlayer";
-@import "./Plink/Plink";
@import "./Question/Question";
@import "./Score/Score";
-@import "./SongSync/SongSync";
@import "./Trial/Trial";
@import "./FeedbackForm/FeedbackForm";
@import "./ToontjeHoger/ToontjeHoger";
diff --git a/frontend/src/index.scss b/frontend/src/index.scss
index 319af4d17..b445b88f9 100644
--- a/frontend/src/index.scss
+++ b/frontend/src/index.scss
@@ -4,6 +4,7 @@
@import "scss/layout";
@import "scss/animations";
@import "scss/elements";
+@import "scss/color-schemes";
@import "scss/fontawesome/include.scss";
diff --git a/frontend/src/scss/color-schemes.scss b/frontend/src/scss/color-schemes.scss
new file mode 100644
index 000000000..7d0bd0b2d
--- /dev/null
+++ b/frontend/src/scss/color-schemes.scss
@@ -0,0 +1,81 @@
+.boolean {
+ // buttons
+ .btn-secondary {
+ &:first-of-type {
+ @include btn-style($positive);
+ }
+ &:nth-of-type(2) {
+ @include btn-style($yellow);
+ }
+ &:last-of-type {
+ @include btn-style($negative);
+ }
+ }
+}
+
+.boolean-negative-first {
+ // buttons
+ .btn-secondary {
+ &:first-of-type {
+ @include btn-style($negative);
+ }
+ &:nth-of-type(2) {
+ @include btn-style($yellow);
+ }
+ &:last-of-type {
+ @include btn-style($positive);
+ }
+ }
+}
+
+.neutral {
+ // buttons
+ .btn-secondary {
+ &:first-of-type {
+ @include btn-style($yellow);
+ }
+ &:last-of-type {
+ @include btn-style($blue);
+ }
+ }
+}
+
+.neutral-inverted {
+ // buttons
+ .btn-secondary {
+ &:first-of-type {
+ @include btn-style($blue);
+ }
+ &:last-of-type {
+ @include btn-style($yellow);
+ }
+ }
+}
+
+.btn.submit {
+ @include btn-style($primary);
+}
+
+.gradient-7 {
+ .is-1 {
+ color: #d843e2;
+ }
+ .is-2 {
+ color: #c863e8;
+ }
+ .is-3 {
+ color: #bb7ae9;
+ }
+ .is-4 {
+ color: #ab86f1;
+ }
+ .is-5 {
+ color: #8b9bfa;
+ }
+ .is-6 {
+ color: #42b5ff;
+ }
+ .is-7 {
+ color: #0cc7f1;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/scss/variables.scss b/frontend/src/scss/variables.scss
index 8d37573d1..f2fdee84d 100644
--- a/frontend/src/scss/variables.scss
+++ b/frontend/src/scss/variables.scss
@@ -10,8 +10,11 @@ $indigo: #2b2bee;
$gray: #bdbebf;
$gray-900: #212529;
$black: $gray-900;
+
$primary: $pink;
$success: $teal;
+$positive: $teal;
+$negative: $red;
$secondary: $gray-900;
$info: $blue;
diff --git a/frontend/src/util/audio.js b/frontend/src/util/audio.js
index 165809685..72ffb8f6b 100644
--- a/frontend/src/util/audio.js
+++ b/frontend/src/util/audio.js
@@ -10,7 +10,7 @@ audio.controls = "controls";
audio.src = SILENT_MP3;
// switch to cors anonymous for local development (needed for webaudio)
-API_ROOT == 'http://localhost:8000' ? audio.crossOrigin = "anonymous" : audio.crossorigin = "use-credentials";
+API_ROOT === 'http://localhost:8000' ? audio.crossOrigin = "anonymous" : audio.crossorigin = "use-credentials";
audio.disableRemotePlayback = true;
audio.style.display = "none";
diff --git a/frontend/src/util/webAudio.js b/frontend/src/util/webAudio.js
index a8c36fd9c..d34450b3e 100644
--- a/frontend/src/util/webAudio.js
+++ b/frontend/src/util/webAudio.js
@@ -1,7 +1,10 @@
+import { MEDIA_ROOT } from "../config";
+
let track;
-let source
+let source;
let buffers = {};
-let audioContext
+let audioContext;
+let previousSource;
export let audioInitialized = false;
@@ -70,19 +73,26 @@ export const changeGain = (level) => {
}
// load sound data and store in buffers object
-export const loadBuffer = async (id, src, canPlay) => {
- await fetch(src, {})
+export const loadBuffer = async (id, src, canPlay) => {
+ await fetch(MEDIA_ROOT + src, {})
// Return the data as an ArrayBuffer
.then(response => response.arrayBuffer())
// Decode the audio data
.then(buffer => audioContext.decodeAudioData(buffer))
// store buffer in buffers object
.then(decodedData => {
- buffers[id] = decodedData;
+ buffers[id] = decodedData;
+ previousSource = src;
canPlay();
});
}
+export const checkSectionLoaded = (section) => {
+ if (section.url === previousSource) {
+ return true;
+ }
+}
+
// Clear buffer list
export const clearBuffers = () => {
buffers = {};