diff --git a/backend/experiment/actions/playback.py b/backend/experiment/actions/playback.py index faa18df80..9ab16ec6d 100644 --- a/backend/experiment/actions/playback.py +++ b/backend/experiment/actions/playback.py @@ -1,56 +1,145 @@ from .base_action import BaseAction +# player types +TYPE_AUTOPLAY = 'AUTOPLAY' +TYPE_BUTTON = 'BUTTON' +TYPE_IMAGE = 'IMAGE' +TYPE_MULTIPLAYER = 'MULTIPLAYER' +TYPE_MATCHINGPAIRS = 'MATCHINGPAIRS' +TYPE_VISUALMATCHINGPAIRS = 'VISUALMATCHINGPAIRS' + +# playback methods +PLAY_EXTERNAL = 'EXTERNAL' +PLAY_HTML = 'HTML' +PLAY_BUFFER = 'BUFFER' +PLAY_NOAUDIO = 'NOAUDIO' + class Playback(BaseAction): - ''' A playback wrapper for different kinds of players - - player_type: can be one of the following: - - 'AUTOPLAY' - player starts automatically - - 'BUTTON' - display one play button - - 'MULTIPLAYER' - display multiple small play buttons, one per section - - 'SPECTROGRAM' - extends multiplayer with a list of spectrograms - - 'MATCHINGPAIRS': Use for the matching pairs game. - - 'VISUALMATCHINGPAIRS': Use for the visual matching pairs game. + ''' A playback base class for different kinds of players - sections: a list of sections (in many cases, will only contain *one* section) - preload_message: text to display during preload - instruction: text to display during presentation of the sound - - play_config: define to override the following values: - - 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 - - 'PREFETCH': Files will be fetched and cached in the browser. - - ready_time: time before presentation of sound - - timeout_after_playback: pause in ms after playback has finished - - playhead: from where the audio file should play (offset in seconds from start) - - mute: whether audio should be muted - - auto_play: whether sound will start automatically - - stop_audio_after: after how many seconds playback audio should be stopped - - show_animation: whether to show an animation during playback - - (multiplayer) label_style: player index number style: NUMERIC, ALPHABETIC, ROMAN or empty (no label) - - play_once: the sound can only be played once - - resume_play: if the playback should resume from where a previous view left off - ''' - - TYPE_AUTOPLAY = 'AUTOPLAY' - TYPE_BUTTON = 'BUTTON' - TYPE_MULTIPLAYER = 'MULTIPLAYER' - TYPE_SPECTROGRAM = 'SPECTROGRAM' - - def __init__(self, sections, player_type='AUTOPLAY', preload_message='', instruction='', play_config=None): + - play_from: where in the audio file to start playing/ + - ready_time: how long to show the "Preload" view (loading spinner) + - show_animation: whether to show animations with this player + - mute: whether to mute the audio + - timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding + - stop_audio_after: stop playback after so many seconds + - resume_play: if the playback should resume from where a previous view left off + ''' + + def __init__(self, + sections, + preload_message='', + instruction='', + play_from=0, + ready_time=0, + show_animation=False, + mute=False, + timeout_after_playback=None, + stop_audio_after=None, + resume_play=False): self.sections = [{'id': s.id, 'url': s.absolute_url(), 'group': s.group} for s in sections] - self.ID = player_type + if str(sections[0].filename).startswith('http'): + self.play_method = PLAY_EXTERNAL + elif sections[0].duration > 45: + self.play_method = PLAY_HTML + else: + self.play_method = PLAY_BUFFER + self.show_animation = show_animation self.preload_message = preload_message self.instruction = instruction - self.play_config = { - 'play_method': 'BUFFER', - 'external_audio': False, - 'ready_time': 0, - 'playhead': 0, - 'show_animation': False, - 'mute': False, - 'play_once': False, - 'resume_play': False - } - if play_config: - self.play_config.update(play_config) + self.play_from = play_from + self.mute = mute + self.ready_time = ready_time + self.timeout_after_playback = timeout_after_playback + self.stop_audio_after = stop_audio_after + self.resume_play = resume_play + + +class Autoplay(Playback): + ''' + This player starts playing automatically + - show_animation: if True, show a countdown and moving histogram + ''' + + def __init__(self, sections, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_AUTOPLAY + + +class PlayButton(Playback): + ''' + This player shows a button, which triggers playback + - play_once: if True, button will be disabled after one play + ''' + + def __init__(self, sections, play_once=False, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_BUTTON + self.play_once = play_once + + +class Multiplayer(PlayButton): + ''' + This is a player with multiple play buttons + - stop_audio_after: after how many seconds to stop audio + - label_style: set if players should be labeled in alphabetic / numeric / roman style (based on player index) + - labels: pass list of strings if players should have custom labels + ''' + + def __init__(self, sections, stop_audio_after=5, labels=[], **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_MULTIPLAYER + self.stop_audio_after = stop_audio_after + if labels: + if len(labels) != len(self.sections): + raise UserWarning( + 'Number of labels and sections for the play buttons do not match') + self.labels = labels + + +class ImagePlayer(PlayButton): + ''' + This is a special case of the Multiplayer: + it shows an image next to each play button + ''' + + def __init__(self, sections, images, image_labels=[], **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_IMAGE + if len(images) != len(self.sections): + raise UserWarning( + 'Number of images and sections for the ImagePlayer do not match') + self.images = images + if image_labels: + if len(image_labels) != len(self.sections): + raise UserWarning( + 'Number of image labels and sections do not match') + self.image_labels = image_labels + + +class MatchingPairs(Multiplayer): + ''' + This is a special case of multiplayer: + play buttons are represented as cards + ''' + + def __init__(self, sections, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_MATCHINGPAIRS + + +class VisualMatchingPairs(MatchingPairs): + ''' + This is a special case of multiplayer: + play buttons are represented as cards + this player does not play audio, but displays images instead + ''' + + def __init__(self, sections, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_VISUALMATCHINGPAIRS + self.play_method = PLAY_NOAUDIO diff --git a/backend/experiment/actions/wrappers.py b/backend/experiment/actions/wrappers.py index 934ad5cdb..4c03d81c4 100644 --- a/backend/experiment/actions/wrappers.py +++ b/backend/experiment/actions/wrappers.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext as _ from .form import BooleanQuestion, ChoiceQuestion, Form -from .playback import Playback +from .playback import Autoplay, PlayButton from .trial import Trial from result.utils import prepare_result @@ -17,9 +17,8 @@ def two_alternative_forced(session, section, choices, expected_response=None, st Provide data for a Two Alternative Forced view that (auto)plays a section, shows a question and has two customizable buttons """ - playback = Playback( - [section], - 'BUTTON' + playback = PlayButton( + [section] ) key = 'choice' button_style = {'invisible-text': True, @@ -46,8 +45,9 @@ def two_alternative_forced(session, section, choices, expected_response=None, st return trial -def song_sync(session, section, title, play_method='BUFFER', - recognition_time=15, sync_time=15, min_jitter=10, max_jitter=15): +def song_sync(session, section, title, + recognition_time=15, sync_time=15, + min_jitter=10, max_jitter=15): trial_config = { 'response_time': recognition_time, 'auto_advance': True @@ -59,31 +59,25 @@ def song_sync(session, section, title, play_method='BUFFER', '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?'), - ), + playback=Autoplay([section], show_animation=True, + ready_time=3, + 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', + playback=Autoplay([section], + show_animation=True, instruction=_('Keep imagining the music'), - play_config={ - 'mute': True, - 'ready_time': 0, - 'show_animation': True, - }), + mute=True), config={ 'response_time': silence_time, 'auto_advance': True, - 'show_continue_button': False + 'show_continue_button': False, }, title=title ) @@ -99,15 +93,13 @@ def song_sync(session, section, title, play_method='BUFFER', scoring_rule='SONG_SYNC_CONTINUATION', expected_response='yes' if continuation_correctness else 'no') )]), - playback=Playback([section], 'AUTOPLAY', + playback=Autoplay([section], instruction=_( 'Did the track come back in the right place?'), - play_config={ - 'ready_time': 0, - 'playhead': randomize_playhead(min_jitter, max_jitter, silence_time, continuation_correctness), - 'show_animation': True, - 'resume_play': True - }), + show_animation=True, + play_from=randomize_playhead( + min_jitter, max_jitter, silence_time, continuation_correctness), + resume_play=True), config=trial_config, title=title ) diff --git a/backend/experiment/rules/__init__.py b/backend/experiment/rules/__init__.py index a51512aa0..20e4934a0 100644 --- a/backend/experiment/rules/__init__.py +++ b/backend/experiment/rules/__init__.py @@ -13,7 +13,7 @@ from .huang_2022 import Huang2022 from .kuiper_2020 import Kuiper2020 from .listening_conditions import ListeningConditions -from .matching_pairs import MatchingPairs +from .matching_pairs import MatchingPairsGame from .matching_pairs_icmpc import MatchingPairsICMPC from .musical_preferences import MusicalPreferences from .rhythm_discrimination import RhythmDiscrimination @@ -29,7 +29,7 @@ from .toontjehoger_4_absolute import ToontjeHoger4Absolute from .toontjehoger_5_tempo import ToontjeHoger5Tempo from .toontjehoger_6_relative import ToontjeHoger6Relative -from .visual_matching_pairs import VisualMatchingPairs +from .visual_matching_pairs import VisualMatchingPairsGame # Rules available to this application # If you create new Rules, add them to the list @@ -46,7 +46,7 @@ BST.ID: BST, Hooked.ID: Hooked, HookedTeleTunes.ID: HookedTeleTunes, - MatchingPairs.ID: MatchingPairs, + MatchingPairsGame.ID: MatchingPairsGame, MatchingPairsICMPC.ID: MatchingPairsICMPC, MusicalPreferences.ID: MusicalPreferences, RhythmDiscrimination.ID: RhythmDiscrimination, @@ -67,5 +67,5 @@ Eurovision2020.ID: Eurovision2020, Kuiper2020.ID: Kuiper2020, ThatsMySong.ID: ThatsMySong, - VisualMatchingPairs.ID: VisualMatchingPairs + VisualMatchingPairsGame.ID: VisualMatchingPairsGame } diff --git a/backend/experiment/rules/anisochrony.py b/backend/experiment/rules/anisochrony.py index d887c6163..a4d0bdd88 100644 --- a/backend/experiment/rules/anisochrony.py +++ b/backend/experiment/rules/anisochrony.py @@ -4,7 +4,7 @@ from section.models import Section from experiment.actions import Trial, Explainer, Step from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.utils import render_feedback_trivia from .duration_discrimination import DurationDiscrimination @@ -65,7 +65,7 @@ def next_trial_action(self, session, trial_condition, difficulty): submits=True ) - playback = Playback([section]) + playback = Autoplay([section]) form = Form([question]) config = { 'listen_first': True, diff --git a/backend/experiment/rules/beat_alignment.py b/backend/experiment/rules/beat_alignment.py index 367ea9270..d024059b5 100644 --- a/backend/experiment/rules/beat_alignment.py +++ b/backend/experiment/rules/beat_alignment.py @@ -6,7 +6,7 @@ from .base import Base from experiment.actions import Trial, Explainer, Consent, StartSession, Step from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.utils import final_action_with_optional_button, render_feedback_trivia from result.utils import prepare_result @@ -108,7 +108,7 @@ def next_practice_action(self, playlist, count): else: presentation_text = _( "In this example the beeps are NOT ALIGNED TO THE BEAT of the music.") - playback = Playback([section], + playback = Autoplay([section], instruction=presentation_text, preload_message=presentation_text, ) @@ -145,7 +145,7 @@ def next_trial_action(self, session, this_round): submits=True ) form = Form([question]) - playback = Playback([section]) + playback = Autoplay([section]) view = Trial( playback=playback, feedback_form=form, diff --git a/backend/experiment/rules/categorization.py b/backend/experiment/rules/categorization.py index 66ef5a5ed..e9666f4dc 100644 --- a/backend/experiment/rules/categorization.py +++ b/backend/experiment/rules/categorization.py @@ -5,7 +5,6 @@ from experiment.actions.form import Form, ChoiceQuestion from experiment.actions import Consent, Explainer, Score, StartSession, Trial, Final from experiment.actions.wrappers import two_alternative_forced -from experiment.questions.utils import unanswered_questions from experiment.questions.demographics import EXTRA_DEMOGRAPHICS from experiment.questions.utils import question_by_key diff --git a/backend/experiment/rules/duration_discrimination.py b/backend/experiment/rules/duration_discrimination.py index 0a711503c..64c5bca37 100644 --- a/backend/experiment/rules/duration_discrimination.py +++ b/backend/experiment/rules/duration_discrimination.py @@ -8,7 +8,7 @@ from section.models import Section from experiment.actions import Trial, Consent, Explainer, StartSession, Step from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.utils import final_action_with_optional_button, render_feedback_trivia from experiment.actions.utils import get_average_difference from experiment.rules.util.practice import get_trial_condition_block, get_practice_views, practice_explainer @@ -140,8 +140,8 @@ def next_trial_action(self, session, trial_condition, difficulty): submits=True ) # create Result object and save expected result to database - - playback = Playback([section]) + + playback = Autoplay([section]) form = Form([question]) view = Trial( playback=playback, diff --git a/backend/experiment/rules/eurovision_2020.py b/backend/experiment/rules/eurovision_2020.py index 398a28769..d632fa752 100644 --- a/backend/experiment/rules/eurovision_2020.py +++ b/backend/experiment/rules/eurovision_2020.py @@ -2,7 +2,7 @@ import random from django.utils.translation import gettext_lazy as _ from experiment.actions import Trial -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.form import BooleanQuestion, Form from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST from experiment.actions.wrappers import song_sync @@ -139,12 +139,13 @@ def next_heard_before_action(self, session): print("Warning: no heard_before section found") 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 = novelty[round_number] + playback = Autoplay( + sections = [section], + show_animation=True, + ready_time=3, + preload_message=_('Get ready!') + ) + expected_result=novelty[round_number] # 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') diff --git a/backend/experiment/rules/h_bat.py b/backend/experiment/rules/h_bat.py index 440bbbe05..e13adcb89 100644 --- a/backend/experiment/rules/h_bat.py +++ b/backend/experiment/rules/h_bat.py @@ -7,7 +7,7 @@ from section.models import Section from experiment.actions import Trial, Consent, Explainer, Playlist, Step, StartSession from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.rules.util.practice import get_practice_views, practice_explainer, get_trial_condition, get_trial_condition_block from experiment.actions.utils import final_action_with_optional_button, render_feedback_trivia @@ -105,7 +105,7 @@ def next_trial_action(self, session, trial_condition, level=1, *kwargs): view='BUTTON_ARRAY', submits=True ) - playback = Playback([section]) + playback = Autoplay([section]) form = Form([question]) view = Trial( playback=playback, diff --git a/backend/experiment/rules/hbat_bst.py b/backend/experiment/rules/hbat_bst.py index b8a661c78..2e29241dd 100644 --- a/backend/experiment/rules/hbat_bst.py +++ b/backend/experiment/rules/hbat_bst.py @@ -3,7 +3,7 @@ from section.models import Section from experiment.actions import Trial, Explainer, Step from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.utils import final_action_with_optional_button, render_feedback_trivia from experiment.actions.utils import get_average_difference_level_based from result.utils import prepare_result @@ -62,7 +62,7 @@ def next_trial_action(self, session, trial_condition, level=1): expected_response=expected_response, scoring_rule='CORRECTNESS'), submits=True ) - playback = Playback([section]) + playback = Autoplay([section]) form = Form([question]) view = Trial( playback=playback, diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index 951891622..af9317fb0 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -8,7 +8,7 @@ from .base import Base 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.actions.playback import Autoplay from experiment.questions.demographics import DEMOGRAPHICS from experiment.questions.goldsmiths import MSI_OTHER from experiment.questions.utils import question_by_key @@ -283,7 +283,7 @@ def next_song_sync_action(self, session, explainers=[]): if not section: logger.warning("Warning: no next_song_sync section found") section = session.section_from_any_song() - return song_sync(session, section, title=self.get_trial_title(session, round_number), play_method=self.play_method, + return song_sync(session, section, title=self.get_trial_title(session, round_number), recognition_time=self.recognition_time, sync_time=self.sync_time, min_jitter=self.min_jitter, max_jitter=self.max_jitter) @@ -307,11 +307,12 @@ def next_heard_before_action(self, session): if not section: logger.warning("Warning: no heard_before section found") section = session.section_from_any_song() - playback = Playback( + playback = Autoplay( [section], - play_config={'ready_time': 3, 'show_animation': True, - 'play_method': self.play_method}, - preload_message=_('Get ready!')) + show_animation=True, + ready_time=3, + preload_message=_('Get ready!') + ) expected_response = this_section_info.get('novelty') # create Result object and save expected result to database key = 'heard_before' diff --git a/backend/experiment/rules/huang_2022.py b/backend/experiment/rules/huang_2022.py index 17c0290de..89c1399ad 100644 --- a/backend/experiment/rules/huang_2022.py +++ b/backend/experiment/rules/huang_2022.py @@ -4,9 +4,9 @@ from django.template.loader import render_to_string from django.conf import settings -from experiment.actions import HTML, Final, Score, Explainer, Step, Consent, StartSession, Redirect, Playlist, Trial -from experiment.actions.form import BooleanQuestion, ChoiceQuestion, Form, Question -from experiment.actions.playback import Playback +from experiment.actions import HTML, Final, Explainer, Step, Consent, StartSession, Redirect, Playlist, Trial +from experiment.actions.form import BooleanQuestion, Form +from experiment.actions.playback import Autoplay from experiment.questions.demographics import EXTRA_DEMOGRAPHICS from experiment.questions.goldsmiths import MSI_ALL, MSI_OTHER from experiment.questions.other import OTHER @@ -82,7 +82,7 @@ def next_round(self, session): if not plan: last_result = session.result_set.last() if not last_result: - playback = get_test_playback(self.play_method) + playback = get_test_playback() html = HTML(body='

{}

'.format(_('Do you hear the music?'))) form = Form(form=[BooleanQuestion( key='audio_check1', @@ -98,7 +98,7 @@ def next_round(self, session): if last_result.score == 0: # user indicated they couldn't hear the music if last_result.question_key == 'audio_check1': - playback = get_test_playback(self.play_method) + playback = get_test_playback() html = HTML(body=render_to_string('html/huang_2022/audio_check.html')) form = Form(form=[BooleanQuestion( key='audio_check2', @@ -233,13 +233,12 @@ def final_score_message(self, session): return " ".join([str(m) for m in messages]) -def get_test_playback(play_method): +def get_test_playback(): from section.models import Section test_section = Section.objects.get(song__name='audiocheck') - playback = Playback(sections=[test_section], - play_config={ - 'play_method': play_method, - 'show_animation': True - }) + playback = Autoplay( + sections=[test_section], + show_animation=True + ) return playback \ No newline at end of file diff --git a/backend/experiment/rules/kuiper_2020.py b/backend/experiment/rules/kuiper_2020.py index 1011e77aa..e661299d7 100644 --- a/backend/experiment/rules/kuiper_2020.py +++ b/backend/experiment/rules/kuiper_2020.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ from experiment.actions import Trial -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.form import BooleanQuestion, Form from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST from experiment.actions.wrappers import song_sync @@ -129,11 +129,13 @@ def next_heard_before_action(self, session): print("Warning: no heard_before section found") section = session.section_from_any_song() - playback = Playback( + playback = Autoplay( [section], - play_config={'ready_time': 3, 'show_animation': True}, - preload_message=_('Get ready!')) - expected_result = novelty[round_number] + show_animation=True, + ready_time=3, + preload_message=_('Get ready!') + ) + expected_result=novelty[round_number] # 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') diff --git a/backend/experiment/rules/listening_conditions.py b/backend/experiment/rules/listening_conditions.py index 067fe0b43..2be61a3af 100644 --- a/backend/experiment/rules/listening_conditions.py +++ b/backend/experiment/rules/listening_conditions.py @@ -2,9 +2,9 @@ from django.utils.translation import gettext_lazy as _ from .base import Base -from experiment.actions import Consent, Explainer, Step, Playback, Playlist, StartSession, Trial +from experiment.actions import Consent, Explainer, Step, Playlist, StartSession, Trial from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.utils import final_action_with_optional_button from result.utils import prepare_result @@ -87,7 +87,7 @@ def next_round(self, session, request_session=None): instruction = _("You can now set the sound to a comfortable level. \ You can then adjust the volume to as high a level as possible without it being uncomfortable. \ When you are satisfied with the sound level, click Continue") - playback = Playback([section], instruction=instruction) + playback = Autoplay([section], instruction=instruction) message = _( "Please keep the eventual sound level the same over the course of the experiment.") actions = [ diff --git a/backend/experiment/rules/matching_pairs.py b/backend/experiment/rules/matching_pairs.py index 7ab39f24f..a96f5aa70 100644 --- a/backend/experiment/rules/matching_pairs.py +++ b/backend/experiment/rules/matching_pairs.py @@ -5,7 +5,7 @@ from .base import Base from experiment.actions import Consent, Explainer, Final, Playlist, StartSession, Step, Trial -from experiment.actions.playback import Playback +from experiment.actions.playback import MatchingPairs from experiment.questions.demographics import EXTRA_DEMOGRAPHICS from experiment.questions.utils import question_by_key from result.utils import prepare_result @@ -13,7 +13,7 @@ from section.models import Section -class MatchingPairs(Base): +class MatchingPairsGame(Base): ID = 'MATCHING_PAIRS' num_pairs = 8 contact_email = 'aml.tunetwins@gmail.com' @@ -108,10 +108,9 @@ def get_matching_pairs_trial(self, session): degradations = session.playlist.section_set.filter(group__in=selected_pairs, tag=degradation_type) player_sections = list(originals) + list(degradations) random.shuffle(player_sections) - playback = Playback( + playback = MatchingPairs( sections=player_sections, - player_type='MATCHINGPAIRS', - play_config={'stop_audio_after': 5} + stop_audio_after=5 ) trial = Trial( title='Tune twins', diff --git a/backend/experiment/rules/musical_preferences.py b/backend/experiment/rules/musical_preferences.py index 464d2ab3f..6ae1fc590 100644 --- a/backend/experiment/rules/musical_preferences.py +++ b/backend/experiment/rules/musical_preferences.py @@ -10,7 +10,7 @@ from experiment.actions import Consent, Explainer, Final, HTML, Playlist, Redirect, Step, StartSession, Trial from experiment.actions.form import BooleanQuestion, ChoiceQuestion, Form, LikertQuestionIcon -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.styles import STYLE_BOOLEAN, STYLE_BOOLEAN_NEGATIVE_FIRST from result.utils import prepare_result @@ -111,9 +111,8 @@ def next_round(self, session, request_session=None): else: session.decrement_round() if last_result.question_key == 'audio_check1': - playback = get_test_playback('EXTERNAL') - html = HTML(body=render_to_string( - 'html/huang_2022/audio_check.html')) + playback = get_test_playback() + html = HTML(body=render_to_string('html/huang_2022/audio_check.html')) form = Form(form=[BooleanQuestion( key='audio_check2', choices={'no': _('Quit'), 'yes': _('Next')}, @@ -132,7 +131,7 @@ def next_round(self, session, request_session=None): return Redirect(settings.HOMEPAGE) else: session.decrement_round() - playback = get_test_playback('EXTERNAL') + playback = get_test_playback() html = HTML( body='

{}

'.format(_('Do you hear the music?'))) form = Form(form=[BooleanQuestion( @@ -218,7 +217,7 @@ def next_round(self, session, request_session=None): result_id=prepare_result(know_key, session, section=section), style=STYLE_BOOLEAN ) - playback = Playback([section], play_config={'show_animation': True}) + playback = Autoplay([section], show_animation=True) form = Form([know, likert]) view = Trial( playback=playback, diff --git a/backend/experiment/rules/rhythm_discrimination.py b/backend/experiment/rules/rhythm_discrimination.py index 25ede35ca..f89a423fa 100644 --- a/backend/experiment/rules/rhythm_discrimination.py +++ b/backend/experiment/rules/rhythm_discrimination.py @@ -6,7 +6,7 @@ from experiment.actions.utils import final_action_with_optional_button, render_feedback_trivia from experiment.rules.util.practice import practice_explainer, practice_again_explainer, start_experiment_explainer from experiment.actions import Trial, Consent, Explainer, StartSession, Step -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.actions.form import ChoiceQuestion, Form from result.utils import prepare_result @@ -173,7 +173,7 @@ def next_trial_actions(session, round_number, request_session): submits=True ) form = Form([question]) - playback = Playback([section]) + playback = Autoplay([section]) if round_number < 5: title = _('practice') else: diff --git a/backend/experiment/rules/speech2song.py b/backend/experiment/rules/speech2song.py index 6ace2d778..4d308b5ea 100644 --- a/backend/experiment/rules/speech2song.py +++ b/backend/experiment/rules/speech2song.py @@ -7,7 +7,7 @@ from experiment.actions import Consent, Explainer, Step, Final, Playlist, Trial, StartSession from experiment.actions.form import Form, RadiosQuestion -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from experiment.questions.demographics import EXTRA_DEMOGRAPHICS from experiment.questions.languages import LANGUAGE, LanguageQuestion from experiment.questions.utils import question_by_key @@ -232,15 +232,10 @@ def sound(section, n_representation=None): ready_time = 0 else: ready_time = 1 - config = { - 'ready_time': ready_time, - 'show_animation': False - } title = _('Listen carefully') - playback = Playback( + playback = Autoplay( sections = [section], - player_type='AUTOPLAY', - play_config=config + ready_time = ready_time, ) view = Trial( playback=playback, diff --git a/backend/experiment/rules/tests/test_hooked.py b/backend/experiment/rules/tests/test_hooked.py index 76866bc6c..273dc4538 100644 --- a/backend/experiment/rules/tests/test_hooked.py +++ b/backend/experiment/rules/tests/test_hooked.py @@ -1,7 +1,6 @@ from django.test import TestCase from experiment.models import Experiment -from experiment.rules import Eurovision2020, Huang2022, ThatsMySong from experiment.questions.musicgens import MUSICGENS_17_W_VARIANTS from participant.models import Participant from result.models import Result diff --git a/backend/experiment/rules/tests/test_matching_pairs.py b/backend/experiment/rules/tests/test_matching_pairs.py index 3b26e1183..b2644a816 100644 --- a/backend/experiment/rules/tests/test_matching_pairs.py +++ b/backend/experiment/rules/tests/test_matching_pairs.py @@ -1,7 +1,6 @@ from django.test import TestCase from experiment.models import Experiment -from experiment.rules import MatchingPairs from participant.models import Participant from section.models import Playlist from session.models import Session diff --git a/backend/experiment/rules/tests/test_visual_matching_pairs.py b/backend/experiment/rules/tests/test_visual_matching_pairs.py index d68e2abfa..fa95af2ab 100644 --- a/backend/experiment/rules/tests/test_visual_matching_pairs.py +++ b/backend/experiment/rules/tests/test_visual_matching_pairs.py @@ -1,7 +1,7 @@ from django.test import TestCase from experiment.models import Experiment -from experiment.rules import VisualMatchingPairs +from experiment.rules import VisualMatchingPairsGame from participant.models import Participant from section.models import Playlist from session.models import Session @@ -35,7 +35,7 @@ def setUpTestData(self): participant=self.participant, playlist=self.playlist ) - self.rules = VisualMatchingPairs() + self.rules = VisualMatchingPairsGame() def test_visual_matching_pairs_trial(self): trial = self.rules.get_visual_matching_pairs_trial(self.session) diff --git a/backend/experiment/rules/toontjehoger_1_mozart.py b/backend/experiment/rules/toontjehoger_1_mozart.py index e1524fd8f..b104c40bb 100644 --- a/backend/experiment/rules/toontjehoger_1_mozart.py +++ b/backend/experiment/rules/toontjehoger_1_mozart.py @@ -3,7 +3,7 @@ from os.path import join from experiment.actions import Trial, Explainer, Step, Score, Final, StartSession, Playlist, Info, HTML from experiment.actions.form import ButtonArrayQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Autoplay from .base import Base from experiment.utils import non_breaking_spaces @@ -141,11 +141,7 @@ def get_image_trial(self, session, section_group, image_url, question, expected_ # -------------------- # Listen - play_config = {'show_animation': True} - playback = Playback([section], - player_type=Playback.TYPE_AUTOPLAY, - play_config=play_config - ) + playback = Autoplay([section], show_animation=True) listen_config = { 'auto_advance': True, diff --git a/backend/experiment/rules/toontjehoger_2_preverbal.py b/backend/experiment/rules/toontjehoger_2_preverbal.py index 4aa159443..67aef7687 100644 --- a/backend/experiment/rules/toontjehoger_2_preverbal.py +++ b/backend/experiment/rules/toontjehoger_2_preverbal.py @@ -4,7 +4,7 @@ from .toontjehoger_1_mozart import toontjehoger_ranks from experiment.actions import Trial, Explainer, Step, Score, Final, StartSession, Playlist, Info, HTML from experiment.actions.form import ButtonArrayQuestion, ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import ImagePlayer from experiment.actions.styles import STYLE_NEUTRAL from .base import Base from os.path import join @@ -159,13 +159,12 @@ def get_round1_playback(self, session): "Error: could not find section C for round 1") # Player - play_config = { - 'label_style': 'ALPHABETIC', - 'spectrograms': ["/images/experiments/toontjehoger/spectrogram-trumpet.webp", "/images/experiments/toontjehoger/spectrogram-whale.webp", "/images/experiments/toontjehoger/spectrogram-human.webp"], - 'spectrogram_labels': ['Trompet', 'Walvis', 'Mens'], - } - playback = Playback( - [sectionA, sectionB, sectionC], player_type=Playback.TYPE_SPECTROGRAM, play_config=play_config) + playback = ImagePlayer( + [sectionA, sectionB, sectionC], + label_style='ALPHABETIC', + images=["/images/experiments/toontjehoger/spectrogram-trumpet.webp", "/images/experiments/toontjehoger/spectrogram-whale.webp", "/images/experiments/toontjehoger/spectrogram-human.webp"], + image_labels = ['Trompet', 'Walvis', 'Mens'] + ) trial = Trial( playback=playback, @@ -192,12 +191,11 @@ def get_round2(self, round, session): "Error: could not find section B for round 2") # Player - play_config = { - 'label_style': 'ALPHABETIC', - 'spectrograms': ["/images/experiments/toontjehoger/spectrogram-baby-french.webp", "/images/experiments/toontjehoger/spectrogram-baby-german.webp"] - } - playback = Playback( - [sectionA, sectionB], player_type=Playback.TYPE_SPECTROGRAM, play_config=play_config) + playback = ImagePlayer( + [sectionA, sectionB], + label_style='ALPHABETIC', + images=["/images/experiments/toontjehoger/spectrogram-baby-french.webp", "/images/experiments/toontjehoger/spectrogram-baby-german.webp"], + ) # Question key = 'baby' diff --git a/backend/experiment/rules/toontjehoger_3_plink.py b/backend/experiment/rules/toontjehoger_3_plink.py index d6ed76a90..dad0327f0 100644 --- a/backend/experiment/rules/toontjehoger_3_plink.py +++ b/backend/experiment/rules/toontjehoger_3_plink.py @@ -3,7 +3,8 @@ from django.template.loader import render_to_string from .toontjehoger_1_mozart import toontjehoger_ranks -from experiment.actions import Explainer, Step, Score, Final, StartSession, Playback, Playlist, Info, Trial +from experiment.actions import Explainer, Step, Score, Final, StartSession, Playlist, Info, Trial +from experiment.actions.playback import PlayButton from experiment.actions.form import AutoCompleteQuestion, RadiosQuestion, Form from .base import Base @@ -95,9 +96,11 @@ def get_score_view(self, session): 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)) + 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)) + 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!" @@ -125,7 +128,7 @@ def get_score_view(self, session): config = {'show_total_score': True} round_number = session.get_relevant_results(['plink']).count() - 1 - score_title = "Ronde %(number)d / %(total)d" %\ + 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) @@ -159,8 +162,7 @@ def get_plink_round(self, session, present_score=False): ) ) next_round.append(Trial( - playback=Playback( - player_type='BUTTON', + playback=PlayButton( sections=[section] ), feedback_form=Form( @@ -247,7 +249,8 @@ def calculate_score(self, result, data): if result.question_key == 'plink': return self.SCORE_MAIN_CORRECT if result.expected_response == result.given_response else self.SCORE_MAIN_WRONG elif result.question_key == 'era': - result.session.save_json_data({'extra_questions_intro_shown': True}) + 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: diff --git a/backend/experiment/rules/toontjehoger_4_absolute.py b/backend/experiment/rules/toontjehoger_4_absolute.py index adde4623b..7f899931b 100644 --- a/backend/experiment/rules/toontjehoger_4_absolute.py +++ b/backend/experiment/rules/toontjehoger_4_absolute.py @@ -6,8 +6,9 @@ from .toontjehoger_1_mozart import toontjehoger_ranks from experiment.actions import Trial, Explainer, Step, Score, Final, StartSession, Playlist, Info from experiment.actions.form import ButtonArrayQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Multiplayer from experiment.actions.styles import STYLE_NEUTRAL +from experiment.utils import create_player_labels from .base import Base from result.utils import prepare_result @@ -97,12 +98,7 @@ def get_round(self, session): random.shuffle(sections) # Player - play_config = { - 'label_style': 'ALPHABETIC', - } - - playback = Playback( - sections, player_type=Playback.TYPE_MULTIPLAYER, play_config=play_config) + playback = Multiplayer(sections, labels=create_player_labels(len(sections), 'alphabetic')) # Question key = 'pitch' diff --git a/backend/experiment/rules/toontjehoger_5_tempo.py b/backend/experiment/rules/toontjehoger_5_tempo.py index 1fdc2377c..a1210edbc 100644 --- a/backend/experiment/rules/toontjehoger_5_tempo.py +++ b/backend/experiment/rules/toontjehoger_5_tempo.py @@ -5,10 +5,10 @@ from .toontjehoger_1_mozart import toontjehoger_ranks from experiment.actions import Trial, Explainer, Step, Score, Final, StartSession, Playlist, Info from experiment.actions.form import ButtonArrayQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Multiplayer from experiment.actions.styles import STYLE_NEUTRAL from .base import Base -from experiment.utils import non_breaking_spaces +from experiment.utils import create_player_labels, non_breaking_spaces from result.utils import prepare_result @@ -136,12 +136,7 @@ def get_round(self, session, round): section_original = sections[0] if sections[0].group == "or" else sections[1] # Player - play_config = { - 'label_style': 'ALPHABETIC', - } - - playback = Playback( - sections, player_type=Playback.TYPE_MULTIPLAYER, play_config=play_config) + playback = Multiplayer(sections, labels=create_player_labels(len(sections), 'alphabetic')) # Question key = 'pitch' diff --git a/backend/experiment/rules/toontjehoger_6_relative.py b/backend/experiment/rules/toontjehoger_6_relative.py index 02630b1be..56d976246 100644 --- a/backend/experiment/rules/toontjehoger_6_relative.py +++ b/backend/experiment/rules/toontjehoger_6_relative.py @@ -4,7 +4,7 @@ from .toontjehoger_1_mozart import toontjehoger_ranks from experiment.actions import Trial, Explainer, Step, Score, Final, StartSession, Playlist, Info from experiment.actions.form import ChoiceQuestion, Form -from experiment.actions.playback import Playback +from experiment.actions.playback import Multiplayer from experiment.actions.styles import STYLE_BOOLEAN from .base import Base @@ -126,13 +126,11 @@ def get_round(self, round, session): form = Form([question]) # Player - play_config = { - 'label_style': 'CUSTOM', - 'labels': ['A', 'B' if round == 0 else 'C'], - 'play_once': True, - } - playback = Playback( - [section1, section2], player_type=Playback.TYPE_MULTIPLAYER, play_config=play_config) + playback = Multiplayer( + [section1, section2], + play_once=True, + labels=['A', 'B' if round == 0 else 'C'] + ) trial = Trial( playback=playback, diff --git a/backend/experiment/rules/visual_matching_pairs.py b/backend/experiment/rules/visual_matching_pairs.py index 25ca1ecd4..273bc95ed 100644 --- a/backend/experiment/rules/visual_matching_pairs.py +++ b/backend/experiment/rules/visual_matching_pairs.py @@ -5,7 +5,7 @@ from .base import Base from experiment.actions import Consent, Explainer, Final, Playlist, StartSession, Step, Trial -from experiment.actions.playback import Playback +from experiment.actions.playback import VisualMatchingPairs from experiment.questions.demographics import EXTRA_DEMOGRAPHICS from experiment.questions.utils import question_by_key from result.utils import prepare_result @@ -13,7 +13,7 @@ from section.models import Section -class VisualMatchingPairs(Base): +class VisualMatchingPairsGame(Base): ID = 'VISUAL_MATCHING_PAIRS' num_pairs = 8 contact_email = 'aml.tunetwins@gmail.com' @@ -92,10 +92,8 @@ def get_visual_matching_pairs_trial(self, session): player_sections = list(session.playlist.section_set.filter(tag__contains='vmp')) random.shuffle(player_sections) - playback = Playback( - sections=player_sections, - player_type='VISUALMATCHINGPAIRS', - play_config={ 'play_method': 'PREFETCH' } + playback = VisualMatchingPairs( + sections=player_sections ) trial = Trial( title='Visual Tune twins', diff --git a/backend/experiment/tests/test_utils.py b/backend/experiment/tests/test_utils.py new file mode 100644 index 000000000..0ae83aea7 --- /dev/null +++ b/backend/experiment/tests/test_utils.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from experiment.utils import create_player_labels + + +class TestExperimentUtils(TestCase): + + def test_create_player_labels(self): + labels = create_player_labels(3, 'alphabetic') + assert labels == ['A', 'B', 'C'] + labels = create_player_labels(4, 'roman') + assert labels == ['I', 'II', 'III', 'IV'] + labels = create_player_labels(2) + assert labels == ['1', '2'] \ No newline at end of file diff --git a/backend/experiment/utils.py b/backend/experiment/utils.py index ed41e8fbd..17dcaaa89 100644 --- a/backend/experiment/utils.py +++ b/backend/experiment/utils.py @@ -1,3 +1,5 @@ +import roman + def serialize(actions): ''' Serialize an array of actions ''' @@ -28,3 +30,16 @@ def non_breaking_spaces(s): def external_url(text, url): # Create a HTML element for an external url return '{}'.format(url, text) + + +def create_player_labels(num_labels, label_style='number'): + return [format_label(i, label_style) for i in range(num_labels)] + + +def format_label(number, label_style): + if label_style == 'alphabetic': + return chr(number + 65) + elif label_style == 'roman': + return roman.toRoman(number+1) + else: + return str(number+1) diff --git a/backend/requirements.in/base.txt b/backend/requirements.in/base.txt index 30aeb7e60..8655debdb 100644 --- a/backend/requirements.in/base.txt +++ b/backend/requirements.in/base.txt @@ -20,6 +20,9 @@ IPToCC # PostgrSQL database client psycopg2 +# to convert labels to Roman numerals +roman + # Sentry SDK, for monitoring performance & errors. See also sentry.io. sentry-sdk diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index 6f30e9e93..27c59c4bd 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -1,6 +1,6 @@ # -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: # # pip-compile --output-file=requirements/dev.txt requirements.in/dev.txt # @@ -101,6 +101,7 @@ requests==2.31.0 # via # -r requirements.in/dev.txt # genbadge +roman==4.1 sentry-sdk==1.38.0 # via -r requirements.in/base.txt six==1.16.0 diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index 81daf2f11..0d03396db 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -1,6 +1,6 @@ # -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: # # pip-compile --output-file=requirements/prod.txt requirements.in/prod.txt # @@ -55,6 +55,7 @@ pytz==2023.3 # pandas requests==2.31.0 # via genbadge +roman==4.1 sentry-sdk==1.38.0 # via -r requirements.in/base.txt six==1.16.0 diff --git a/frontend/src/components/Playback/Autoplay.js b/frontend/src/components/Playback/Autoplay.js index 10bb080a0..f58d76c5e 100644 --- a/frontend/src/components/Playback/Autoplay.js +++ b/frontend/src/components/Playback/Autoplay.js @@ -1,17 +1,16 @@ -import React, { useRef, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import Circle from "../Circle/Circle"; import ListenCircle from "../ListenCircle/ListenCircle"; -const AutoPlay = ({instruction, playConfig, playSection, time, startedPlaying, finishedPlaying, responseTime, className=''}) => { - // player state +const AutoPlay = ({instruction, showAnimation, playSection, startedPlaying, finishedPlaying, responseTime, className=''}) => { - const running = useRef(playConfig.auto_play); + const [running, setRunning] = useState(true); // Handle view logic useEffect(() => { playSection(0) - }, [playConfig, startedPlaying]); + }, [playSection, startedPlaying]); // Render component return ( @@ -21,14 +20,15 @@ const AutoPlay = ({instruction, playConfig, playSection, time, startedPlaying, f running={running} duration={responseTime} color="white" - animateCircle={playConfig.show_animation} + animateCircle={showAnimation} onFinish={() => { // Stop audio + setRunning(false); finishedPlaying(); }} />
- {playConfig.show_animation + {showAnimation ? { +const ImagePlayer = (props) => { const playSection = props.playSection; // extraContent callback can be used to add content to each player const extraContent = useCallback( (index) => { - const spectrograms = props.playConfig.spectrograms; - if (!spectrograms) { - return

Warning: No spectrograms found

; + const images = props.images; + if (!images) { + return

Warning: No images found

; } - const labels = props.playConfig.spectrogram_labels; + const labels = props.image_labels; - if (index >= 0 && index < spectrograms.length) { + if (index >= 0 && index < images.length) { return ( -
+
Spectrogram { playSection(index); }} @@ -32,14 +32,14 @@ const SpectrogramPlayer = (props) => { return

Warning: No spectrograms available for index {index}

; } }, - [props.playConfig.spectrograms, props.playConfig.spectrogram_labels, playSection] + [props.images, props.image_labels, playSection] ); return ( -
+
); }; -export default SpectrogramPlayer; +export default ImagePlayer; diff --git a/frontend/src/components/Playback/SpectrogramPlayer.scss b/frontend/src/components/Playback/ImagePlayer.scss similarity index 97% rename from frontend/src/components/Playback/SpectrogramPlayer.scss rename to frontend/src/components/Playback/ImagePlayer.scss index 726896acb..a037c6530 100644 --- a/frontend/src/components/Playback/SpectrogramPlayer.scss +++ b/frontend/src/components/Playback/ImagePlayer.scss @@ -1,4 +1,4 @@ -.aha__spectrogram-player { +.aha__image-player { max-width: 100vw; .player-wrapper { @@ -10,7 +10,7 @@ margin-bottom: 0; } - .spectrogram { + .image { max-width: 400px; width: calc(100% - 160px); height: 100px; diff --git a/frontend/src/components/Playback/MatchingPairs.js b/frontend/src/components/Playback/MatchingPairs.js index f38431d21..0a1f65418 100644 --- a/frontend/src/components/Playback/MatchingPairs.js +++ b/frontend/src/components/Playback/MatchingPairs.js @@ -7,8 +7,8 @@ const MatchingPairs = ({ playSection, sections, playerIndex, - finishedPlaying, stopAudioAfter, + finishedPlaying, submitResult, }) => { const xPosition = useRef(-1); diff --git a/frontend/src/components/Playback/MatchingPairs.test.js b/frontend/src/components/Playback/MatchingPairs.test.js index 4ceac1208..dbc35fb9f 100644 --- a/frontend/src/components/Playback/MatchingPairs.test.js +++ b/frontend/src/components/Playback/MatchingPairs.test.js @@ -12,7 +12,8 @@ describe('MatchingPairs Component', () => { playSection: jest.fn(), playerIndex: 0, finishedPlaying: jest.fn(), - stopAudioAfter: jest.fn(), + onFinish: jest.fn(), + stopAudioAfter: 4.0, submitResult: jest.fn(), }; diff --git a/frontend/src/components/Playback/MultiPlayer.js b/frontend/src/components/Playback/MultiPlayer.js index 4029d516f..b7cff4418 100644 --- a/frontend/src/components/Playback/MultiPlayer.js +++ b/frontend/src/components/Playback/MultiPlayer.js @@ -2,13 +2,11 @@ import React from "react"; import PlayerSmall from "../PlayButton/PlayerSmall"; import classNames from "classnames"; -import { getPlayerLabel } from "../../util/label"; - const MultiPlayer = ({ playSection, sections, playerIndex, - playConfig, + labels, disabledPlayers, extraContent, }) => { @@ -30,13 +28,7 @@ const MultiPlayer = ({ disabledPlayers.includes(parseInt(index)) } label={ - playConfig.label_style - ? getPlayerLabel( - index, - playConfig.label_style, - playConfig.labels || [] - ) - : "" + labels? labels[index] : "" } playing={playerIndex === index} /> diff --git a/frontend/src/components/Playback/Playback.js b/frontend/src/components/Playback/Playback.js index 345c75dc9..535721dab 100644 --- a/frontend/src/components/Playback/Playback.js +++ b/frontend/src/components/Playback/Playback.js @@ -7,7 +7,7 @@ import { playAudio, pauseAudio } from "../../util/audioControl"; import AutoPlay from "./Autoplay"; import PlayButton from "../PlayButton/PlayButton"; import MultiPlayer from "./MultiPlayer"; -import SpectrogramPlayer from "./SpectrogramPlayer"; +import ImagePlayer from "./ImagePlayer"; import MatchingPairs from "./MatchingPairs"; import Preload from "../Preload/Preload"; import VisualMatchingPairs from "components/VisualMatchingPairs/VisualMatchingPairs"; @@ -15,20 +15,16 @@ import VisualMatchingPairs from "components/VisualMatchingPairs/VisualMatchingPa export const AUTOPLAY = "AUTOPLAY"; export const BUTTON = "BUTTON"; export const MULTIPLAYER = "MULTIPLAYER"; -export const SPECTROGRAM = "SPECTROGRAM"; +export const IMAGE = "IMAGE"; export const MATCHINGPAIRS = "MATCHINGPAIRS"; export const PRELOAD = "PRELOAD"; export const VISUALMATCHINGPAIRS = "VISUALMATCHINGPAIRS"; const Playback = ({ - playerType, - sections, - instruction, + playbackArgs, onPreloadReady, - preloadMessage = '', autoAdvance, responseTime, - playConfig = {}, submitResult, startedPlaying, finishedPlaying, @@ -40,6 +36,8 @@ const Playback = ({ const setView = (view, data = {}) => { setState({ view, ...data }); } + const playMethod = playbackArgs.play_method; + const sections = playbackArgs.sections; // Keep track of which player has played, in a an array of player indices const [hasPlayed, setHasPlayed] = useState([]); @@ -71,106 +69,92 @@ const Playback = ({ const onAudioEnded = useCallback(() => { setPlayerIndex(-1); - //AJ: added for categorization experiment for form activation after playback and auto_advance to work properly - if (playConfig.timeout_after_playback) { - setTimeout(finishedPlaying, playConfig.timeout_after_playback); + if (playbackArgs.timeout_after_playback) { + setTimeout(finishedPlaying, playbackArgs.timeout_after_playback); } else { finishedPlaying(); } - }, []); - + }, [playbackArgs, finishedPlaying]); + // Keep track of last player index useEffect(() => { lastPlayerIndex.current = playerIndex; }, [playerIndex]); - if (playConfig.play_method === 'EXTERNAL') { - webAudio.closeWebAudio(); + if (playMethod === 'EXTERNAL') { + webAudio.closeWebAudio(); } - // Play section with given index - const playSection = useCallback( - (index = 0) => { - - if (index !== lastPlayerIndex.current) { - // Load different audio - if (prevPlayerIndex.current !== -1) { - pauseAudio(playConfig); - } - // Store player index - setPlayerIndex(index); - - // Determine if audio should be played - if (playConfig.mute) { - setPlayerIndex(-1); - pauseAudio(playConfig); - return; - } - const playheadShift = getPlayheadShift(); - let latency = playAudio(playConfig, sections[index], playheadShift); - - // Cancel active events - cancelAudioListeners(); - - // listen for active audio events - if (playConfig.play_method === 'BUFFER') { - activeAudioEndedListener.current = webAudio.listenOnce("ended", onAudioEnded); - } else { - activeAudioEndedListener.current = audio.listenOnce("ended", onAudioEnded); - } - - // Compensate for audio latency and set state to playing - setTimeout(startedPlaying && startedPlaying(), latency); - return; - } - - // Stop playback - if (lastPlayerIndex.current === index) { - pauseAudio(playConfig); - setPlayerIndex(-1); - return; - } - }, - [playAudio, pauseAudio, sections, activeAudioEndedListener, cancelAudioListeners, startedPlaying, onAudioEnded] - ); - - const getPlayheadShift = () => { + const getPlayheadShift = useCallback(() => { /* if the current Playback view has resume_play set to true, retrieve previous Playback view's decisionTime from sessionStorage */ - return playConfig.resume_play ? + return playbackArgs.resume_play ? parseFloat(window.sessionStorage.getItem('decisionTime')) : 0; - } + }, [playbackArgs] + ) + + // Play section with given index + const playSection = useCallback((index = 0) => { + if (index !== lastPlayerIndex.current) { + // Load different audio + if (prevPlayerIndex.current !== -1) { + pauseAudio(playMethod); + } + // Store player index + setPlayerIndex(index); + // Determine if audio should be played + if (playbackArgs.mute) { + setPlayerIndex(-1); + pauseAudio(playMethod); + return; + } + + const playheadShift = getPlayheadShift(); + let latency = playAudio(sections[index], playMethod, playheadShift + playbackArgs.play_from); + // Cancel active events + cancelAudioListeners(); + // listen for active audio events + if (playMethod === 'BUFFER') { + activeAudioEndedListener.current = webAudio.listenOnce("ended", onAudioEnded); + } else { + activeAudioEndedListener.current = audio.listenOnce("ended", onAudioEnded); + } + // Compensate for audio latency and set state to playing + setTimeout(startedPlaying && startedPlaying(), latency); + return; + } + // Stop playback + if (lastPlayerIndex.current === index) { + pauseAudio(playMethod); + setPlayerIndex(-1); + return; + } + }, [sections, activeAudioEndedListener, cancelAudioListeners, getPlayheadShift, playbackArgs, playMethod, startedPlaying, onAudioEnded] + ); // Local logic for onfinished playing const onFinishedPlaying = useCallback(() => { setPlayerIndex(-1); - pauseAudio(playConfig); + pauseAudio(playMethod); finishedPlaying && finishedPlaying(); - }, [finishedPlaying]); + }, [finishedPlaying, playMethod]); // Stop audio on unmount useEffect( - () => () => { - pauseAudio(playConfig); + () => { + return () => pauseAudio(playMethod); }, - [] + [playMethod] ); - // Autoplay - useEffect(() => { - playConfig.auto_play && playSection(0); - }, [playConfig.auto_play, playSection]); - const render = (view) => { const attrs = { sections, + showAnimation: playbackArgs.show_animation, setView, - instruction, - preloadMessage, autoAdvance, responseTime, - playConfig, startedPlaying, playerIndex, finishedPlaying: onFinishedPlaying, @@ -183,46 +167,48 @@ const Playback = ({ switch (state.view) { case PRELOAD: return ( - { - setView(playerType); + { + setView(playbackArgs.view); onPreloadReady(); }} /> ); case AUTOPLAY: - return ; + return ; case BUTTON: return ( -1} - disabled={playConfig.play_once && hasPlayed.includes(0)} + disabled={playbackArgs.play_once && hasPlayed.includes(0)} /> ); case MULTIPLAYER: return ( ); - case SPECTROGRAM: + case IMAGE: return ( - ); case MATCHINGPAIRS: return ( ); case VISUALMATCHINGPAIRS: @@ -238,7 +224,7 @@ const Playback = ({ return (
-
{render(playerType)}
{" "} +
{render(playbackArgs.view)}
{" "}
); }; diff --git a/frontend/src/components/Playback/Playback.test.js b/frontend/src/components/Playback/Playback.test.js new file mode 100644 index 000000000..52a56aa72 --- /dev/null +++ b/frontend/src/components/Playback/Playback.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, wait } from '@testing-library/react'; + +import Playback from './Playback'; + +describe('Playback', () => { + + const basicProps = { + autoAdvance: false, + responseTime: 42, + onPreloadReady: jest.fn(), + startedPlaying: jest.fn(), + finishedPlaying: jest.fn(), + submitResult: jest.fn(), + } + + let playbackArgs = { + view: 'BUTTON', + show_animation: false, + instruction: 'Listen, just listen!', + play_method: 'HTML', + ready_time: 1, + preload_message: 'Get ready', + sections: [{id: 13, url: 'some/fancy/tune.mp3'}] + }; + + it('renders itself', () => { + const { container } = render( + ); + expect(container.querySelector('.aha__playback')).toBeInTheDocument(); + }); + + it('shows Preload during ready_time', () => { + const { container } = render( + ); + expect(container.querySelector('.aha__listen')).toBeInTheDocument(); + }); + +}) \ No newline at end of file diff --git a/frontend/src/components/Preload/Preload.js b/frontend/src/components/Preload/Preload.js index e65cb765d..eb3fa258b 100644 --- a/frontend/src/components/Preload/Preload.js +++ b/frontend/src/components/Preload/Preload.js @@ -1,41 +1,41 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; +import classNames from "classnames"; +import { MEDIA_ROOT } from "../../config"; import ListenFeedback from "../Listen/ListenFeedback"; import CountDown from "../CountDown/CountDown"; import * as audio from "../../util/audio"; import * as webAudio from "../../util/webAudio"; -import { MEDIA_ROOT } from "../../config"; -import classNames from "classnames"; // Preload is an experiment screen that continues after a given time or after an audio file has been preloaded -const Preload = ({ instruction, pageTitle, duration, sections, playConfig, onNext }) => { - const timeHasPassed = useRef(false); - const audioIsAvailable = useRef(false); - const [loaderDuration, setLoaderDuration] = useState(duration); +const Preload = ({ sections, playMethod, duration, preloadMessage, pageTitle, onNext }) => { + const [timePassed, setTimePassed] = useState(false); + const [audioAvailable, setAudioAvailable] = useState(false); const [overtime, setOvertime] = useState(false); - + const [loaderDuration, setLoaderDuration] = useState(duration); + const onTimePassed = () => { - timeHasPassed.current = true; + setTimePassed(true) setLoaderDuration(0); setOvertime(true); - if (audioIsAvailable.current) { + if (audioAvailable) { onNext(); } }; + // Audio preloader useEffect(() => { const preloadResources = async () => { - - if (playConfig.play_method === 'PREFETCH') { + if (playMethod === 'NOAUDIO') { await Promise.all(sections.map((section) => fetch(MEDIA_ROOT + section.url))); return onNext(); } - if (playConfig.play_method === 'BUFFER') { + if (playMethod === 'BUFFER') { - // Use Web-audio and preload sections in buffers + // 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)) { @@ -47,40 +47,40 @@ const Preload = ({ instruction, pageTitle, duration, sections, playConfig, onNex webAudio.clearBuffers(); } - // Load sections in buffer - return webAudio.loadBuffer(section.id, section.url, () => { + // Load sections in buffer + return webAudio.loadBuffer(section.id, section.url, () => { if (index === (sections.length - 1)) { - audioIsAvailable.current = true; - if (timeHasPassed.current) { + setAudioAvailable(true); + if (timePassed) { onNext(); - } - } + } + } }); }) } else { - if (playConfig.play_method === 'EXTERNAL') { - webAudio.closeWebAudio(); + if (playMethod === 'EXTERNAL') { + webAudio.closeWebAudio(); } // Load audio until available - // Return remove listener - return audio.loadUntilAvailable(MEDIA_ROOT + sections[0].url, () => { - audioIsAvailable.current = true; - if (timeHasPassed.current) { + // Return remove listener + return audio.loadUntilAvailable(sections[0].url, () => { + setAudioAvailable(true); + if (timePassed) { onNext(); } - }); + }); } } - preloadResources(); - }, [sections, onNext, playConfig]); - + preloadResources(); + }, [sections, playMethod, onNext, timePassed]); + return ( = 1 && } /> diff --git a/frontend/src/components/Trial/Trial.js b/frontend/src/components/Trial/Trial.js index c04759a2b..6ba2b74ed 100644 --- a/frontend/src/components/Trial/Trial.js +++ b/frontend/src/components/Trial/Trial.js @@ -23,7 +23,7 @@ const Trial = ({ }) => { // Main component state const [formActive, setFormActive] = useState(!config.listen_first); - const [preloadReady, setPreloadReady] = useState(!playback?.play_config?.ready_time); + const [preloadReady, setPreloadReady] = useState(!(playback?.ready_time)); const submitted = useRef(false); @@ -81,7 +81,7 @@ const Trial = ({ } }, - [feedback_form, config, onNext, onResult] + [feedback_form, config, onNext, onResult, result_id] ); const checkBreakRound = (values, breakConditions) => { @@ -108,8 +108,8 @@ const Trial = ({ if (config.auto_advance) { // Create a time_passed result - if (config.auto_advance_timer != null) { - if (playback.player_type === 'BUTTON') { + if (config.auto_advance_timer != null) { + if (playback.view === 'BUTTON') { startTime.current = getCurrentTime(); } @@ -131,16 +131,12 @@ const Trial = ({
{playback && ( { setPreloadReady(true); }} - preloadMessage={playback.preload_message} autoAdvance={config.auto_advance} responseTime={config.response_time} - playConfig={playback.play_config} - sections={playback.sections} submitResult={makeResult} startedPlaying={startTimer} finishedPlaying={finishedPlaying} diff --git a/frontend/src/components/components.scss b/frontend/src/components/components.scss index 393489d5b..1100b623a 100644 --- a/frontend/src/components/components.scss +++ b/frontend/src/components/components.scss @@ -34,7 +34,7 @@ @import "./Playback/Multiplayer"; @import "./Playback/Playback"; @import "./Playback/MatchingPairs"; -@import "./Playback/SpectrogramPlayer"; +@import "./Playback/ImagePlayer"; @import "./Question/Question"; @import "./Score/Score"; @import "./Trial/Trial"; diff --git a/frontend/src/util/audio.js b/frontend/src/util/audio.js index 72ffb8f6b..271f35fbe 100644 --- a/frontend/src/util/audio.js +++ b/frontend/src/util/audio.js @@ -1,4 +1,4 @@ -import { API_ROOT, SILENT_MP3 } from "../config.js"; +import { API_ROOT, MEDIA_ROOT, SILENT_MP3 } from "../config.js"; import Timer from "./timer"; // Audio provides function around a shared audio object @@ -170,7 +170,7 @@ export const loadUntilAvailable = (src, canPlay) => { // without having to stop for further buffering of content. const removeListener = listenOnce("canplaythrough", canPlay); - load(src); + load(MEDIA_ROOT + src); // If the ready state is already > 3, data is already loaded; // Call canPlay right away diff --git a/frontend/src/util/audioControl.js b/frontend/src/util/audioControl.js index 0e8b61458..ce1e8ed4a 100644 --- a/frontend/src/util/audioControl.js +++ b/frontend/src/util/audioControl.js @@ -1,22 +1,21 @@ import * as audio from "./audio"; import * as webAudio from "./webAudio"; -export const playAudio = (playConfig, section, playheadShift=0) => { +export const playAudio = (section, playMethod, playheadShift=0) => { let latency = 0; - const playhead = playConfig.playhead + playheadShift - if (playConfig.play_method === 'BUFFER') { + if (playMethod === 'BUFFER') { // Determine latency for current audio device latency = webAudio.getTotalLatency() // Play audio - webAudio.playBufferFrom(section.id, playhead); + webAudio.playBufferFrom(section.id, playheadShift); return latency } else { // Only initialize webaudio if section is hosted local - if (playConfig.play_method !== 'EXTERNAL') { + if (playMethod !== 'EXTERNAL') { // Determine latency for current audio device latency = webAudio.getTotalLatency() webAudio.initWebAudio(); @@ -26,14 +25,14 @@ export const playAudio = (playConfig, section, playheadShift=0) => { audio.setVolume(1); // Play audio - audio.playFrom(Math.max(0, playhead)); + audio.playFrom(Math.max(0, playheadShift)); return latency } } -export const pauseAudio = (playConfig) => { - if (playConfig.play_method === 'BUFFER') { +export const pauseAudio = (playMethod) => { + if (playMethod === 'BUFFER') { webAudio.stopBuffer(); } else { audio.stop(); diff --git a/frontend/src/util/label.js b/frontend/src/util/label.js index eec6271fb..86ea9cf3b 100644 --- a/frontend/src/util/label.js +++ b/frontend/src/util/label.js @@ -1,32 +1,3 @@ -import { romanNumeral } from "./roman"; - -export const LABEL_NUMERIC = "NUMERIC"; -export const LABEL_ALPHABETIC = "ALPHABETIC"; -export const LABEL_CUSTOM = "CUSTOM"; -export const LABEL_ROMAN = "ROMAN"; - -/** - * @deprecated This function is deprecated and will be removed in the future. - * See also https://github.com/Amsterdam-Music-Lab/MUSCLE/pull/640 - * Get a player label, based on index, labelstyle and customLabels - */ -export const getPlayerLabel = (index, labelStyle, customLabels) => { - index = parseInt(index); - - switch (labelStyle) { - case LABEL_NUMERIC: - return parseInt(index) + 1; - case LABEL_ALPHABETIC: - return String.fromCharCode(65 + index); - case LABEL_ROMAN: - return romanNumeral(index + 1); - case LABEL_CUSTOM: - return customLabels[index] || ""; - default: - return ""; - } -}; - export const renderLabel = (label, size="fa-lg") => { if (!label) return label if (label.startsWith('fa-')) return diff --git a/frontend/src/util/label.test.js b/frontend/src/util/label.test.js index e27736501..b97f434d7 100644 --- a/frontend/src/util/label.test.js +++ b/frontend/src/util/label.test.js @@ -1,35 +1,5 @@ import { render } from '@testing-library/react'; -import { getPlayerLabel, LABEL_NUMERIC, LABEL_ALPHABETIC, LABEL_ROMAN, LABEL_CUSTOM, renderLabel } from "./label"; - -describe('getPlayerLabel', () => { - - it('returns numeric label correctly', () => { - expect(getPlayerLabel(0, LABEL_NUMERIC)).toBe(1); - expect(getPlayerLabel(1, LABEL_NUMERIC)).toBe(2); - }); - - it('returns alphabetic label correctly', () => { - expect(getPlayerLabel(0, LABEL_ALPHABETIC)).toBe('A'); - expect(getPlayerLabel(25, LABEL_ALPHABETIC)).toBe('Z'); - }); - - it('returns roman label correctly', () => { - expect(getPlayerLabel(0, LABEL_ROMAN)).toBe('I'); - expect(getPlayerLabel(3, LABEL_ROMAN)).toBe('IV'); - }); - - it('returns custom label correctly', () => { - const customLabels = ['One', 'Two', 'Three']; - expect(getPlayerLabel(0, LABEL_CUSTOM, customLabels)).toBe('One'); - expect(getPlayerLabel(2, LABEL_CUSTOM, customLabels)).toBe('Three'); - }); - - it('returns empty string for unknown label style', () => { - expect(getPlayerLabel(1, 'UNKNOWN')).toBe(''); - }); - -}); - +import { renderLabel } from "./label"; describe('renderLabel', () => { diff --git a/frontend/src/util/roman.js b/frontend/src/util/roman.js deleted file mode 100644 index 6663a95eb..000000000 --- a/frontend/src/util/roman.js +++ /dev/null @@ -1,21 +0,0 @@ -export const romanNumeral = (int) => { - let roman = ''; - - if (int < 0 || !int) return roman; - - roman += 'M'.repeat(int / 1000); int %= 1000; - roman += 'CM'.repeat(int / 900); int %= 900; - roman += 'D'.repeat(int / 500); int %= 500; - roman += 'CD'.repeat(int / 400); int %= 400; - roman += 'C'.repeat(int / 100); int %= 100; - roman += 'XC'.repeat(int / 90); int %= 90; - roman += 'L'.repeat(int / 50); int %= 50; - roman += 'XL'.repeat(int / 40); int %= 40; - roman += 'X'.repeat(int / 10); int %= 10; - roman += 'IX'.repeat(int / 9); int %= 9; - roman += 'V'.repeat(int / 5); int %= 5; - roman += 'IV'.repeat(int / 4); int %= 4; - roman += 'I'.repeat(int); - - return roman; -} diff --git a/frontend/src/util/roman.test.js b/frontend/src/util/roman.test.js deleted file mode 100644 index 322b6ffe2..000000000 --- a/frontend/src/util/roman.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { romanNumeral } from './roman'; - -describe('romanNumeral', () => { - it('converts basic numbers correctly', () => { - expect(romanNumeral(1)).toBe('I'); - expect(romanNumeral(5)).toBe('V'); - expect(romanNumeral(10)).toBe('X'); - expect(romanNumeral(50)).toBe('L'); - expect(romanNumeral(100)).toBe('C'); - expect(romanNumeral(500)).toBe('D'); - expect(romanNumeral(1000)).toBe('M'); - }); - - it('converts composite numbers correctly', () => { - expect(romanNumeral(23)).toBe('XXIII'); - expect(romanNumeral(44)).toBe('XLIV'); - expect(romanNumeral(89)).toBe('LXXXIX'); - expect(romanNumeral(199)).toBe('CXCIX'); - expect(romanNumeral(499)).toBe('CDXCIX'); - }); - - it('handles subtractive notation correctly', () => { - expect(romanNumeral(4)).toBe('IV'); - expect(romanNumeral(9)).toBe('IX'); - expect(romanNumeral(40)).toBe('XL'); - expect(romanNumeral(90)).toBe('XC'); - expect(romanNumeral(400)).toBe('CD'); - expect(romanNumeral(900)).toBe('CM'); - expect(romanNumeral(444)).toBe('CDXLIV'); - expect(romanNumeral(999)).toBe('CMXCIX'); - }); - - it('converts large numbers correctly', () => { - expect(romanNumeral(1984)).toBe('MCMLXXXIV'); - expect(romanNumeral(2022)).toBe('MMXXII'); - expect(romanNumeral(3999)).toBe('MMMCMXCIX'); - expect(romanNumeral(4444)).toBe('MMMMCDXLIV'); - expect(romanNumeral(9999)).toBe('MMMMMMMMMCMXCIX'); - }); - - it('handles edge cases correctly', () => { - expect(romanNumeral(0)).toBe(''); - expect(romanNumeral(-1)).toBe(''); - }); -}); diff --git a/frontend/src/util/webAudio.js b/frontend/src/util/webAudio.js index d34450b3e..a113a3ec7 100644 --- a/frontend/src/util/webAudio.js +++ b/frontend/src/util/webAudio.js @@ -105,14 +105,6 @@ export const stopBuffer = () => { } } -// play buffer by section.id -export const playBuffer = (id) => { - source = audioContext.createBufferSource(); - source.buffer = buffers[id]; - source.connect(audioContext.destination); - source.start(); -} - // Play buffer from given time export const playBufferFrom = (id, time) => { source = audioContext.createBufferSource();