Source code for psynet.modular_page

import itertools
import json
import random
import shutil
import tempfile
import warnings
from typing import Dict, List, Optional, Union

from dominate import tags
from dominate.dom_tag import dom_tag
from dominate.util import raw
from markupsafe import Markup

from .asset import Asset, LocalStorage
from .bot import BotResponse
from .timeline import Event, FailedValidation, MediaSpec, Page, Trigger, is_list_of
from .utils import (
    NoArgumentProvided,
    call_function,
    call_function_with_context,
    get_logger,
    get_translator,
    is_valid_html5_id,
    linspace,
)

logger = get_logger()

DEFAULT_LOCALE = "en"


[docs] class Blob: """ Imitates the blob objects which are returned from the Flask front-end; used for testing. """ def __init__(self, file): self.file = file def save(self, dest): shutil.copyfile(self.file, dest)
[docs] class Prompt: """ The ``Prompt`` class displays some kind of media to the participant, to which they will have to respond. Currently the prompt must be written as a Jinja2 macro in ``templates/macros.html``. In the future, we will update the API to allow macros to be defined in external files. Parameters ---------- text Optional text to display to the participant. This can either be a string, which will be HTML-escaped and displayed as regular text, or an HTML string as produced by ``markupsafe.Markup``. text_align CSS alignment of the text. buttons An optional list of additional buttons to include on the page. Normally these will be created by calls to :class:`psynet.modular_page.Button`. Attributes ---------- macro : str The name of the Jinja2 macro as defined within the respective template file. metadata : Object Metadata to save about the prompt; can take arbitrary form, but must be serialisable to JSON. media : MediaSpec Optional object of class :class:`~psynet.timeline.MediaSpec` that provisions media resources for the prompt. external_template : Optional[str] Optionally specifies a custom Jinja2 template from which the prompt macro should be sourced. If not provided, the prompt macro will be assumed to be located in PsyNet's built-in ``prompt.html`` file. """ def __init__( self, text: Union[None, str, Markup] = None, text_align: str = "left", buttons: Optional[List] = None, ): self.text = text self.text_align = text_align if isinstance(text, str): self.text_html = tags.p(text) else: self.text_html = text if buttons is None: buttons = [] self.buttons = buttons macro = "simple" external_template = None @property def metadata(self): # Sometimes `self.text` will be a `markupsafe.Markup` object, which will be encoded # strangely by jsonpickle. We call `str()` to ensure a simpler representation. return {"text": str(self.text)} @property def media(self): return MediaSpec() def visualize(self, trial): if self.text is None: return "" elif isinstance(self.text, Markup): return str(self.text) else: return tags.p(self.text).render() def pre_render(self): pass def update_events(self, events): pass
[docs] class AudioPrompt(Prompt): """ Plays an audio file to the participant. Parameters ---------- audio Audio file to play. Can be an ``Asset`` object, or alternatively a URL written as a string. text Text to display to the participant. This can either be a string for plain text, or an HTML specification from ``markupsafe.Markup``. loop Whether the audio should loop back to the beginning after finishing. text_align CSS alignment of the text. play_window An optional two-element list identifying the time window in the audio file that should be played. If the first element is ``None``, then the audio file is played from the beginning; otherwise, the audio file starts playback from this timepoint (in seconds) (note that negative numbers will not be accepted here). If the second element is ``None``, then the audio file is played until the end; otherwise, the audio file finishes playback at this timepoint (in seconds). The behaviour is undefined when the time window extends past the end of the audio file. controls Whether to give the user playback controls (default = ``False``). fade_in Fade-in duration for the audio (defaults to ``0.0``). fade_out Fade-out duration for the audio (defaults to ``0.0``). kwargs Passed to :class:`~psynet.modular_page.Prompt`. """ def __init__( self, audio, text: Union[str, Markup, dom_tag], loop: bool = False, text_align="left", play_window: Optional[List] = None, controls: bool = False, fade_in: float = 0.0, fade_out: float = 0.0, **kwargs, ): from .asset import Asset if fade_out > 0.0: warnings.warn( "There is a bug in the underlying implementation of fade_out that causes the audio to stop playing " "prematurely when the audio device has high latency. This applies especially to Bluetooth devices. " "Until this bug is fixed, we recommend avoiding use of this parameter and instead editing the audio " "file itself to include a fade-out at the end." ) if play_window is None: play_window = [None, None] assert len(play_window) == 2 if play_window[0] is not None and play_window[0] < 0: raise ValueError("play_window[0] may not be less than 0") if isinstance(audio, Asset): url = audio.url assert url is not None elif isinstance(audio, str): url = audio else: raise TypeError(f"Invalid type for audio argument: {type(audio)}") super().__init__(text=text, text_align=text_align, **kwargs) self.url = url self.loop = loop self.play_window = play_window self.controls = controls self.js_play_options = dict( start=play_window[0], end=play_window[1], fade_in=fade_in, fade_out=fade_out, ) macro = "audio" @property def metadata(self): return { "text": str(self.text), "url": self.url, "play_window": self.play_window, } @property def media(self): return MediaSpec(audio={"prompt": self.url}) def visualize(self, trial): start, end = tuple(self.play_window) src = f"{self.url}#t={'' if start is None else start},{'' if end is None else end}" html = ( super().visualize(trial) + "\n" + tags.audio( tags.source(src=src), id="visualize-audio-prompt", controls=True ).render() ) return html def update_events(self, events): super().update_events(events) events["promptStart"] = Event( is_triggered_by=[ Trigger( triggering_event="trialStart", delay=0, ) ] ) events["promptEnd"] = Event(is_triggered_by=[], once=False) events["trialFinish"].add_trigger("promptEnd")
[docs] class VideoPrompt(Prompt): """ Plays a video file to the participant. Parameters ---------- video Video file to play. Can be an ``Asset`` object, or alternatively a URL written as a string. text Text to display to the participant. This can either be a string for plain text, or an HTML specification from ``markupsafe.Markup``. text_align CSS alignment of the text. width Width of the video frame to be displayed. Default: "560px". height Height of the video frame to be displayed. Default is "auto" whereby the height is automatically adjusted to match the width. play_window An optional two-element list identifying the time window in the video file that should be played. If a list is provided, the first element must be a number specifying the timepoint in seconds at which the video should begin. The second element may then either be ``None``, in which case the video is played until the end, or a number specifying the timepoint in seconds at which the video should end. controls Determines whether the user should be given controls for manipulating video playback. muted If ``True``, then the video will be muted (i.e. it will play without audio). The default is ``False``. hide_when_finished If ``True`` (default), the video will disappear once it has finished playing. mirrored Whether to mirror the video on playback. Default: `False`. kwargs Passed to :class:`~psynet.modular_page.Prompt`. """ def __init__( self, video, text: Union[str, Markup], text_align: str = "left", width: str = "560px", height: str = "auto", play_window: Optional[List] = None, controls: bool = False, muted: bool = False, hide_when_finished: bool = True, mirrored: bool = False, **kwargs, ): from .asset import Asset if play_window is None: play_window = [0.0, None] assert len(play_window) == 2 assert play_window[0] is not None assert play_window[0] >= 0.0 if isinstance(video, Asset): url = video.url elif isinstance(video, str): url = video else: raise TypeError(f"Invalid type for video argument: {type(video)}") super().__init__(text=text, text_align=text_align, **kwargs) self.url = url self.width = width self.height = height self.play_window = play_window self.mirrored = mirrored self.js_play_options = dict( start_at=play_window[0], end_at=play_window[1], muted=muted, controls=controls, hide_when_finished=hide_when_finished, ) macro = "video" @property def metadata(self): return { "text": str(self.text), "url": self.url, "play_window": self.play_window, "mirrored": self.mirrored, } @property def media(self): return MediaSpec(video={"prompt": self.url}) def visualize(self, trial): start, end = tuple(self.play_window) src = f"{self.url}#t={'' if start is None else start},{'' if end is None else end}" html = ( super().visualize(trial) + "\n" + tags.video( tags.source(src=src), id="visualize-video-prompt", controls=True ).render() ) return html def update_events(self, events): super().update_events(events) events["promptStart"] = Event( is_triggered_by=[ Trigger( triggering_event="trialStart", delay=0, ) ], once=True, ) events["promptEnd"] = Event(is_triggered_by=None, once=False) events["trialFinish"].add_trigger("promptEnd")
[docs] class ImagePrompt(Prompt): """ Displays an image to the participant. Parameters ---------- url URL of the image to show. text Text to display to the participant. This can either be a string for plain text, or an HTML specification from ``markupsafe.Markup``. width CSS width specification for the image (e.g. ``'50%'``). height CSS height specification for the image (e.g. ``'50%'``). ``'auto'`` will choose the height automatically to match the width; the disadvantage of this is that other page content may move once the image loads. show_after Specifies the time in seconds when the image will be displayed, calculated relative to the start of the trial. Defaults to 0.0. hide_after If not ``None``, specifies a time in seconds after which the image should be hidden. margin_top CSS specification of the image's top margin. margin_bottom CSS specification of the image's bottom margin. text_align CSS alignment of the text. """ def __init__( self, url: str, text: Union[str, Markup], width: str, height: str, show_after: float = 0.0, hide_after: Optional[float] = None, margin_top: str = "0px", margin_bottom: str = "0px", text_align: str = "left", ): super().__init__(text=text, text_align=text_align) if isinstance(url, Asset): url = url.url self.url = url self.width = width self.height = height self.show_after = show_after self.hide_after = hide_after self.margin_top = margin_top self.margin_bottom = margin_bottom macro = "image" @property def metadata(self): return { "text": str(self.text), "url": self.url, "show_after": self.show_after, "hide_after": self.hide_after, } def update_events(self, events): events["promptStart"] = Event( is_triggered_by="trialStart", delay=self.show_after ) if self.hide_after is not None: events["promptEnd"] = Event( is_triggered_by="promptStart", delay=self.hide_after )
[docs] class ColorPrompt(Prompt): """ Displays a color to the participant. Parameters ---------- color Color to show, specified as a list of HSL values. text Text to display to the participant. This can either be a string for plain text, or an HTML specification from ``markupsafe.Markup``. width CSS width specification for the color box (default ``'200px'``). height CSS height specification for the color box (default ``'200px'``). text_align CSS alignment of the text. """ def __init__( self, color: List[float], text: Union[str, Markup], width: str = "200px", height: str = "200px", text_align: str = "left", ): assert isinstance(color, list) super().__init__(text=text, text_align=text_align) self.hsl = color self.width = width self.height = height macro = "color" @property def metadata(self): return {"text": str(self.text), "hsl": self.hsl}
[docs] class Control: """ The ``Control`` class provides some kind of controls for the participant, with which they will provide their response. Parameters ---------- bot_response : Defines how bots respond to this page. Can be a single value, in which case this is interpreted as the participant's (formatted) answer. Alternatively, it can be an instance of class ``BotResponse``, which can accept more detailed information, for example: raw_answer : The raw_answer returned from the page. answer : The (formatted) answer, as would ordinarily be computed by ``format_answer``. metadata : A dictionary of metadata. blobs : A dictionary of blobs returned from the front-end. client_ip_address : The client's IP address. buttons : An optional list of additional buttons to include on the page. Normally these will be created by calls to :class:`psynet.modular_page.Button`. show_next_button : Determines whether a 'next' button is shown on the page. This button is used to submit the response to the present page. If this is not set to ``True``, then the response must be submitted another way, for example by triggering the event ``manualSubmit``. Attributes ---------- macro : str The name of the Jinja2 macro as defined within the respective template file. metadata : Object Metadata to save about the prompt; can take arbitrary form, but must be serialisable to JSON. media : MediaSpec Optional object of class :class:`~psynet.timeline.MediaSpec` that provisions media resources for the controls. external_template : Optional[str] Optionally specifies a custom Jinja2 template from which the control macro should be sourced. If not provided, the control macro will be assumed to be located in PsyNet's built-in ``control.html`` file. """ external_template = None def __init__( self, bot_response=NoArgumentProvided, locale=DEFAULT_LOCALE, buttons: Optional[List] = None, show_next_button: Optional[bool] = True, ): self.page = None self._bot_response = bot_response self.locale = locale if buttons is None: buttons = [] self.buttons = buttons self.show_next_button = show_next_button @property def macro(self): raise NotImplementedError @property def metadata(self): return {} @property def media(self): return MediaSpec()
[docs] def format_answer(self, raw_answer, **kwargs): """ Formats the raw answer object returned from the participant's browser. Parameters ---------- raw_answer The raw answer object returned from the participant's browser. **kwargs Keyword arguments, including: 1. ``blobs``: A dictionary of any blobs that were returned from the participant's browser. 2. ``metadata``: The metadata returned from the participant's browser. 3. ``experiment``: An instantiation of :class:`psynet.experiment.Experiment`, corresponding to the current experiment. 4. ``participant``: An instantiation of :class:`psynet.participant.Participant`, corresponding to the current participant. 5. ``trial``: An instantiation of :class:`psynet.trial.main.Trial`, corresponding to the participant's current trial, or ``None`` if the participant is not currently taking a trial. Returns ------- Object The formatted answer, suitable for serialisation to JSON and storage in the database. """ # pylint: disable=unused-argument return raw_answer
[docs] def validate(self, response, **kwargs): # pylint: disable=unused-argument """ Takes the :class:`psynet.timeline.Response` object created by the page and runs a validation check to determine whether the participant may continue to the next page. Parameters ---------- response: An instance of :class:`psynet.timeline.Response`. Typically the ``answer`` attribute of this object is most useful for validation. **kwargs: Keyword arguments, including: 1. ``experiment``: An instantiation of :class:`psynet.experiment.Experiment`, corresponding to the current experiment. 2. ``participant``: An instantiation of :class:`psynet.participant.Participant`, corresponding to the current participant. 3. ``answer``: The formatted answer returned by the participant. 4. ``raw_answer``: The unformatted answer returned by the participant. 5. ``page``: The page to which the participant is responding. Returns ------- ``None`` or an object of class :class:`psynet.timeline.FailedValidation` On the case of failed validation, an instantiation of :class:`psynet.timeline.FailedValidation` containing a message to pass to the participant. """ return None
def visualize_response(self, answer, response, trial): return "" def pre_render(self): pass def update_events(self, events): pass def call__get_bot_response(self, experiment, bot, page, prompt): if self._bot_response == NoArgumentProvided: res = self.get_bot_response(experiment, bot, page, prompt) elif callable(self._bot_response): res = call_function_with_context( self._bot_response, experiment=experiment, bot=bot, participant=bot, page=page, prompt=prompt, ) else: res = self._bot_response if not isinstance(res, BotResponse): res = BotResponse(answer=res) return res
[docs] def get_bot_response(self, experiment, bot, page, prompt): """ This function is used when a bot simulates a participant responding to a given page. In the simplest form, the function just returns the value of the answer that the bot returns. For more sophisticated treatment, the function can return a ``BotResponse`` object which contains other parameters such as ``blobs`` and ``metadata``. """ raise NotImplementedError( f"The get_bot_response method for class {self.__class__.__name__} has yet to be implemented." "You will want to implement it yourself, or otherwise pass a bot_response argument to your page's constructor." )
[docs] class NullControl(Control): """ Here the participant just has a single button that takes them to the next page. """ # The macro is named blank, not null, for back-compatibility reasons macro = "blank" metadata = {}
[docs] def get_bot_response(self, experiment, bot, page, prompt): return None
[docs] class OptionControl(Control): """ The OptionControl class provides four kinds of controls for the participant in its subclasses ``CheckboxControl``, ``DropdownControl``, ``PushButtonControl``, and ``RadioButtonControl``. """ def __init__( self, choices: List[str], labels: Optional[List[str]] = None, style: str = "", bot_response=NoArgumentProvided, **kwargs, ): super().__init__(bot_response=bot_response, **kwargs) self.choices = choices self.labels = choices if labels is None else labels self.style = style assert isinstance(self.labels, list) assert len(self.choices) == len(self.labels) def validate_name(self, name): if not isinstance(name, str): raise ValueError("name must be a string") if not is_valid_html5_id(name): raise ValueError("name must be a valid HTML5 id") @property def input_type(self): raise NotImplementedError @property def metadata(self): return { "name": self.name, "choices": self.choices, "labels": self.labels, "force_selection": self.force_selection, }
[docs] def get_bot_response(self, experiment, bot, page, prompt): return BotResponse( answer=random.choice(self.choices), metadata=self.metadata, )
[docs] class CheckboxControl(OptionControl): """ This control interface solicits a multiple-choice response from the participant using checkboxes. Parameters ---------- name: Name of the checkbox group. choices: The different options the participant has to choose from. labels: An optional list of textual labels to apply to the checkboxes, which the participant will see instead of ``choices``. Default: ``None``. arrange_vertically: Whether to arrange the checkboxes vertically. Default: ``True``. style: CSS style attributes to apply to the checkboxes. Default: ``""``. force_selection: Determines if at least checkbox has to be ticked. Default: False. show_reset_button Whether to display a 'Reset' button to allow for unsetting ticked checkboxes. Possible values are: `never`, `always`, and `on_selection`, the latter meaning that the button is displayed only when at least one checkbox is ticked. Default: ``never``. """ input_type = "checkbox" def __init__( self, choices: List[str], labels: Optional[List[str]] = None, style: str = "", name: str = "checkboxes", arrange_vertically: bool = True, force_selection: bool = False, show_reset_button: str = "never", locale=DEFAULT_LOCALE, ): if show_reset_button != "never": buttons = [ResetButton()] else: buttons = [] super().__init__(choices, labels, style, buttons=buttons) self.validate_name(name) self.name = name self.arrange_vertically = arrange_vertically self.force_selection = force_selection self.show_reset_button = show_reset_button self.checkboxes = [ Checkbox( name=self.name, id_=choice, label=label, style=self.style, ) for choice, label in zip(self.choices, self.labels) ] self.locale = locale macro = "checkboxes" def visualize_response(self, answer, response, trial): html = tags.div() with html: for choice, label in zip(self.choices, self.labels): tags.input_( type="checkbox", id=choice, name=self.name, value=choice, checked=( True if answer is not None and choice in answer else False ), ) tags.span(label) tags.br() return html.render()
[docs] def validate(self, response, **kwargs): _, _p = get_translator(self.locale) if self.force_selection and len(response.answer) == 0: return FailedValidation( _p("validation", "You need to check at least one answer!") ) return None
class Checkbox: def __init__(self, id_, *, name, label, start_disabled=False, style=""): self.id = id_ self.name = name self.label = label self.start_disabled = start_disabled self.style = style class DropdownOption: def __init__(self, value, text): self.value = value self.text = text
[docs] class PushButtonControl(OptionControl): """ This control interface solicits a multiple-choice response from the participant. Parameters ---------- choices: The different options the participant has to choose from. labels: An optional list of textual labels to apply to the buttons, which the participant will see instead of ``choices``. Default: ``None``. style: CSS styles to apply to the buttons. Default: ``"min-width: 100px; margin: 10px"``. arrange_vertically: Whether to arrange the buttons vertically. Default: ``True``. """ def __init__( self, choices: List[Union[str, float, int]], labels: Optional[List[str]] = None, style: str = "min-width: 100px; margin: 10px", arrange_vertically: bool = True, show_next_button: bool = False, **kwargs, ): super().__init__( choices, labels, style, show_next_button=show_next_button, **kwargs ) self.arrange_vertically = arrange_vertically self.push_buttons = [ PushButton( button_id=choice, label=label, style=self.style, arrange_vertically=self.arrange_vertically, timed=self.timed, ) for choice, label in zip(self.choices, self.labels) ] macro = "push_buttons" timed = False @property def metadata(self): return {"choices": self.choices, "labels": self.labels} def visualize_response(self, answer, response, trial): html = tags.div() with html: for choice, label in zip(self.choices, self.labels): response_string = response.response.replace('"', "") _class = "btn push-button btn-primary response submit" _class = ( _class.replace("btn-primary", "btn-success") if response_string == choice else _class ) tags.button( type="button", id=choice, _class=_class, style=self.style, ).add(label) tags.br() return html.render()
[docs] class TimedPushButtonControl(PushButtonControl): """ This presents a multiple-choice push-button interface to the participant. The participant can press as many buttons as they like, and the timing of each press will be recorded. They advance to the next page by pressing a 'Next' button. Parameters ---------- choices: The different options the participant has to choose from. labels: An optional list of textual labels to apply to the buttons, which the participant will see instead of ``choices``. Default: ``None``. button_highlight_duration: How long to highlight the button for once it has been clicked (seconds). Defaults to 0.75 s. style: CSS styles to apply to the buttons. Default: ``"min-width: 100px; margin: 10px"``. arrange_vertically: Whether to arrange the buttons vertically. Default: ``True``. **kwargs Other arguments to pass to :class:`~psynet.modular_page.PushButtonControl`. """ timed = True def __init__( self, choices: List[str], labels: Optional[List[str]] = None, button_highlight_duration: float = 0.75, **kwargs, ): super().__init__( choices=choices, labels=labels, show_next_button=True, **kwargs ) self.button_highlight_duration = button_highlight_duration
[docs] def format_answer(self, raw_answer, **kwargs): return {**kwargs}["metadata"]["event_log"]
def visualize_response(self, answer, response, trial): html = tags.div() with html: for choice, label in zip(self.choices, self.labels): response_string = response.response.replace('"', "") _class = "btn push-button btn-primary response timed" _class = ( _class.replace("btn-primary", "btn-success") if response_string == choice else _class ) tags.button( type="button", id=choice, _class=_class, style=self.style, ).add(label) tags.br() return html.render()
class PushButton: def __init__( self, button_id, *, label, style, arrange_vertically, start_disabled=False, timed=False, ): self.id = button_id self.label = label self.style = style self.start_disabled = start_disabled self.display = "block" if arrange_vertically else "inline" self.timed = timed
[docs] class RadioButtonControl(OptionControl): """ This control interface solicits a multiple-choice response from the participant using radiobuttons. Parameters ---------- choices: The different options the participant has to choose from. labels: An optional list of textual labels to apply to the radiobuttons, which the participant will see instead of ``choices``. style: CSS style attributes to apply to the radiobuttons. Default: ``"cursor: pointer"``. name: Name of the radiobutton group. arrange_vertically: Whether to arrange the radiobuttons vertically. force_selection Determines if an answer has to be selected. Default: ``True``. show_reset_button Whether to display a 'Reset' button to allow for unsetting a ticked radiobutton. Possible values are: `never`, `always`, and `on_selection`, the latter meaning that the button is displayed only when a radiobutton is ticked. Default: ``never``. show_free_text_option Appends a free text option to the radiobuttons. Default: ``False``. """ input_type = "radio" def __init__( self, choices: List[str], labels: Optional[List[str]] = None, style: str = "cursor: pointer;", name: str = "radiobuttons", arrange_vertically: bool = True, force_selection: bool = True, show_reset_button: str = "never", show_free_text_option: bool = False, placeholder_text_free_text: str = None, locale=DEFAULT_LOCALE, ): if show_reset_button != "never": buttons = [ResetButton()] else: buttons = [] super().__init__(choices, labels, style, buttons=buttons) self.validate_name(name) self.name = name self.arrange_vertically = arrange_vertically self.force_selection = force_selection self.show_reset_button = show_reset_button self.show_free_text_option = show_free_text_option self.placeholder_text_free_text = placeholder_text_free_text self.radiobuttons = [ RadioButton(name=self.name, id_=choice, label=label, style=self.style) for choice, label in zip(self.choices, self.labels) ] if self.show_free_text_option: placeholder_text = ( "" if self.placeholder_text_free_text is None else f'placeholder="{self.placeholder_text_free_text}"' ) self.radiobuttons.append( RadioButton( name=self.name, id_="free_text", label=Markup( f"<input id='free_text_input' {placeholder_text} type='text'>" ), style=self.style, ) ) self.locale = locale macro = "radiobuttons" def visualize_response(self, answer, response, trial): html = tags.div() with html: for choice, label in zip(self.choices, self.labels): tags.input_( type="radio", id=choice, name=self.name, value=choice, checked=(True if choice == answer else False), ) tags.span(label) tags.br() return html.render()
[docs] def validate(self, response, **kwargs): _, _p = get_translator(self.locale) if self.force_selection and response.answer is None: return FailedValidation(_p("validation", "You need to select an answer!")) return None
class RadioButton: def __init__( self, id_, *, name, label, start_disabled=False, style="cursor: pointer" ): self.id = id_ self.name = name self.label = label self.start_disabled = start_disabled self.style = style
[docs] class NumberControl(Control): """ This control interface solicits number input from the participant. Parameters ---------- width: CSS width property for the text box. Default: `"120px"`. text_align: CSS width property for the alignment of the text inside the number input field. Default: `"right"`. """ def __init__( self, width: Optional[str] = "120px", text_align: Optional[str] = "right", bot_response=NoArgumentProvided, locale=DEFAULT_LOCALE, ): super().__init__(bot_response=bot_response, locale=locale) self.width = width self.text_align = text_align macro = "number" @property def metadata(self): return {"width": self.width, "text_align": self.text_align}
[docs] def validate(self, response, **kwargs): _, _p = get_translator(self.locale) try: float(response.answer) except ValueError: return FailedValidation(_p("validation", "You need to provide a number!")) return None
[docs] def get_bot_response(self, experiment, bot, page, prompt): return random.randint(20, 100)
[docs] class TextControl(Control): """ This control interface solicits free text from the participant. Parameters ---------- one_line: Whether the text box should comprise solely one line. width: CSS width property for the text box. height: CSS height property for the text box. text_align: CSS width property for the alignment of the text inside the text input field. Default: `"left"`. block_copy_paste: Whether to block the copy, cut and paste options in the text input box. """ def __init__( self, one_line: bool = True, width: Optional[str] = None, # e.g. "100px" height: Optional[str] = None, text_align: str = "left", block_copy_paste: bool = False, bot_response=NoArgumentProvided, ): super().__init__(bot_response) if one_line and height is not None: raise ValueError("If <one_line> is True, then <height> must be None.") self.one_line = one_line self.width = width self.height = height self.text_align = text_align self.block_copy_paste = block_copy_paste macro = "text" @property def metadata(self): return { "one_line": self.one_line, "width": self.width, "height": self.height, "text_align": self.text_align, "block_copy_paste": self.block_copy_paste, }
[docs] def get_bot_response(self, experiment, bot, page, prompt): return "Hello, I am a bot!"
class BaseButton: def render(self): raise NotImplementedError class NextButton(BaseButton): def render(self): return "{{ psynet_controls.next_button(button_params) }}" class ResetButton(BaseButton): def render(self): return "{{ psynet_controls.reset_button(control_config) }}"
[docs] class Button(BaseButton): """ Buttons can be included into modular pages via the page's ``buttons`` argument. These buttons can be used to trigger custom events. Parameters ---------- id_ : The button's ID. This must be written in camelCase. When the button is pressed, an event is triggered with this exact ID. text : Text to display on the button. style : Optional CSS styling for the button. is_response_button : If set to ``True``, then the button is treated as a 'response' button and is only enabled once the PsyNet responseEnable event is triggered. start_disabled : If set to ``True``, then the button starts disabled. disable_on_click : If set to ``True``, then the button is disabled when it is clicked, typically to avoid redundant clicks. """ def __init__( self, id_: str, text: Union[str, dom_tag], style: str = "", is_response_button=False, start_disabled=False, disable_on_click=False, ): if not id_.startswith("button"): raise ValueError( "Button IDs must be in camelCase and start with the text 'button'." ) if "_" in id_ or "-" in id_: raise ValueError( f"Invalid button ID '{id_}': button IDs must be written in camelCase." ) self.id = id_ self.text = text self.style = style self.is_response_button = is_response_button self.start_disabled = start_disabled self.disable_on_click = disable_on_click def render(self): return "{{ psynet_controls.generic_button(button_params) }}" @property def classes(self): classes = ["btn", "btn-primary", "btn-lg"] if self.is_response_button: classes.append("response") return " ".join(classes)
[docs] class StartButton(Button): def __init__(self): super().__init__( id_="buttonStart", text="Start", start_disabled=True, disable_on_click=True )
[docs] class ModularPage(Page): """ The :class:`~psynet.modular_page.ModularPage` class provides a way of defining pages in terms of two primary components: the :class:`~psynet.modular_page.Prompt` and the :class:`~psynet.modular_page.Control`. The former determines what is presented to the participant; the latter determines how they may respond. Parameters ---------- label Internal label to give the page, used for example in results saving. prompt A :class:`~psynet.modular_page.Prompt` object that determines the prompt to be displayed to the participant. Alternatively, you can also provide text or a ``markupsafe.Markup`` object, which will then be automatically wrapped in a :class:`~psynet.modular_page.Prompt` object. control A :class:`~psynet.modular_page.Control` object that determines the participant's response controls. time_estimate Time estimated for the page. media Optional specification of media assets to preload (see the documentation for :class:`psynet.timeline.MediaSpec`). Typically this field can be left blank, as media will be passed through the :class:`~psynet.modular_page.Prompt` or :class:`~psynet.modular_page.Control` objects instead. js_vars Optional dictionary of arguments to instantiate as global Javascript variables. start_trial_automatically If ``True`` (default), the trial starts automatically, e.g. by the playing of a queued audio file. Otherwise the trial will wait for the trialPrepare event to be triggered (e.g. by clicking a 'Play' button, or by calling `psynet.trial.registerEvent("trialPrepare")` in JS). buttons An optional list of additional buttons to include on the page. Normally these will be created by calls to :class:`psynet.modular_page.Button`. show_start_button Determines whether a 'start' button is shown on the page. The default is ``False``, but one might consider setting this to ``True`` if ``start_trial_automatically`` is set to ``False``. show_next_button Determines whether a 'next' button is shown on the page. The default is ``None``, which means that the decision is deferred to the selected Control. If set to ``True`` or ``False``, the default from the Control is overridden. validate Optional validation function to use for the participant's response. If left blank, then the validation function will instead be read from the provided Control. Alternatively, the validation function can be set by overriding this class's ``validate`` method. If no validation function is found, no validation is performed. Validation functions provided via the present route may contain various optional arguments. Most typically the function will be of the form ``lambda answer: ...` or ``lambda answer, participant: ...``, but it is also possible to include the arguments ``raw_answer``, ``response``, ``page``, and ``experiment``. Note that ``raw_answer`` is the answer before applying ``format_answer``, and ``answer`` is the answer after applying ``format_answer``. Validation functions should return ``None`` if the validation passes, or if it fails a string corresponding to a message to pass to the participant. For example, a validation function testing that the answer contains exactly 3 characters might look like this: ``lambda answer: "Answer must contain exactly 3 characters!" if len(answer) != 3 else None``. layout Determines the layout of elements in the page. Should take the form of a list that enumerates the page elements in order of appearance. If left blank, defaults to ``.default_layout``. **kwargs Further arguments to be passed to :class:`psynet.timeline.Page`. """ default_layout = ["prompt", "media", "progress", "control", "buttons"] def __init__( self, label: str, prompt: Union[str, dom_tag, Prompt], control: Optional[Control] = None, time_estimate: Optional[float] = None, media: Optional[MediaSpec] = None, events: Optional[dict] = None, js_vars: Optional[dict] = None, start_trial_automatically: bool = True, buttons: Optional[List] = None, show_start_button: Optional[bool] = False, show_next_button: Optional[bool] = None, validate: Optional[callable] = None, layout=NoArgumentProvided, **kwargs, ): if control is None: control = NullControl() if media is None: media = MediaSpec() if js_vars is None: js_vars = {} if buttons is None: buttons = [] if not isinstance(prompt, Prompt): prompt = Prompt(prompt) self.prompt = prompt self.control = control if show_start_button: buttons.append(StartButton()) buttons += prompt.buttons buttons += control.buttons if show_next_button or (show_next_button is None and control.show_next_button): buttons.append(NextButton()) self.buttons = buttons if self.control.page is not None: raise ValueError( "This `Control` object already belongs to another `ModularPage` object. " "This usually happens if you create a single `Control` object and assign it " "to multiple modular pages. This pattern is not supported. Please instead " "create a fresh `Control` object to pass to this modular page. " "Hint: try replacing your original `Control` definition with a function that returns " "a fresh `Control` object each time." ) self.control.page = self self._validate_function = validate if layout == NoArgumentProvided: layout = self.default_layout self.layout = layout template_str = f""" {{% extends "timeline-page.html" %}} {self.import_templates} {{% block main_body %}} {self.render_layout()} {{% endblock %}} """ all_media = MediaSpec.merge(media, prompt.media, control.media) super().__init__( label=label, time_estimate=time_estimate, template_str=template_str, template_arg={ "prompt_config": prompt, "control_config": control, "buttons": buttons, }, media=all_media, events=events, js_vars={ **js_vars, "modular_page_components": { "prompt": self.prompt.macro, "control": self.control.macro, }, }, start_trial_automatically=start_trial_automatically, validate=validate, **kwargs, ) def get_renderers(self, **kwargs): return { "prompt": "{{ %s(prompt_config) }}" % self.prompt_macro, "media": "{{ media.media_container() }}", "control": "{{ %s(control_config) }}" % self.control_macro, "buttons": self.render_buttons(), "progress": "{{ progress.trial_progress_display(trial_progress_display_config) }}", } def render_layout(self, **kwargs): renderers = self.get_renderers() return "\n".join([renderers[key] for key in self.layout])
[docs] def validate(self, response, **kwargs): if self._validate_function is None: return self.control.validate(response, **kwargs) else: return super().validate(response, **kwargs)
def prepare_default_events(self): events = super().prepare_default_events() self.prompt.update_events(events) self.control.update_events(events) return events @property def prompt_macro(self): if self.prompt.external_template is None: location = "psynet_prompts" else: location = "custom_prompt" return f"{location}.{self.prompt.macro}" @property def control_macro(self): if self.control.external_template is None: location = "psynet_controls" else: location = "custom_control" return f"{location}.{self.control.macro}" @property def import_templates(self): return self.import_internal_templates + self.import_external_templates def render_buttons(self): logic = [] for i, button in enumerate(self.buttons): logic.append(f"{{% set button_params = buttons[{i}] %}}") logic.append(button.render()) return "\n".join(logic) @property def import_internal_templates(self): # We explicitly import these internal templates here to ensure # they're imported by the time we try to call them. return """ {% import "macros/prompt.html" as psynet_prompts %} {% import "macros/control.html" as psynet_controls %} """ @property def import_external_templates(self): return " ".join( [ f'{{% import "{path}" as {name} with context %}}' for path, name in zip( [self.prompt.external_template, self.control.external_template], ["custom_prompt", "custom_control"], ) if path is not None ] ) def visualize(self, trial): prompt = self.prompt.visualize(trial) response = self.control.visualize_response( answer=trial.answer, response=trial.response, trial=trial ) div = tags.div(id="trial-visualization") div_style = ( "background-color: white; padding: 10px; " "margin-top: 10px; margin-bottom: 10px; " "border-style: solid; border-width: 1px;" ) with div: if prompt != "": tags.h3("Prompt"), tags.div(raw(prompt), id="prompt-visualization", style=div_style) if prompt != "" and response != "": tags.br() if response != "": tags.h3("Response"), tags.div(raw(response), id="response-visualization", style=div_style) return div.render()
[docs] def format_answer(self, raw_answer, **kwargs): """ By default, the ``format_answer`` method is extracted from the page's :class:`~psynet.page.Control` member. """ return self.control.format_answer(raw_answer=raw_answer, **kwargs)
[docs] def metadata(self, **kwargs): """ By default, the metadata attribute combines the metadata of the :class:`~psynet.page.Prompt` member. and the :class:`~psynet.page.Control` members. """ return {"prompt": self.prompt.metadata, "control": self.control.metadata}
[docs] def pre_render(self): """ This method is called immediately prior to rendering the page for the participant. It will be called again each time the participant refreshes the page. """ self.prompt.pre_render() self.control.pre_render()
[docs] def get_bot_response(self, experiment, bot): page = self prompt = self.prompt return self.control.call__get_bot_response(experiment, bot, page, prompt)
[docs] class AudioMeterControl(Control): macro = "audio_meter" def __init__( self, calibrate: bool = False, show_next_button: bool = True, min_time: float = 0.0, bot_response=NoArgumentProvided, **kwargs, ): if "submit_button" in kwargs: raise ValueError( "The 'submit_button' argument in AudioMeterControl has been renamed to 'show_next_button'." ) super().__init__(bot_response, show_next_button=show_next_button, **kwargs) self.calibrate = calibrate self.min_time = min_time if calibrate: self.sliders = MultiSliderControl( [ Slider( "decay-display", "Decay (display)", self.decay["display"], 0, 3, 0.001, ), Slider( "decay-high", "Decay (too high)", self.decay["high"], 0, 3, 0.001, ), Slider( "decay-low", "Decay (too low)", self.decay["low"], 0, 3, 0.001 ), Slider( "threshold-high", "Threshold (high)", self.threshold["high"], -60, 0, 0.01, ), Slider( "threshold-low", "Threshold (low)", self.threshold["low"], -60, 0, 0.01, ), Slider( "grace-high", "Grace period (too high)", self.grace["high"], 0, 5, 0.001, ), Slider( "grace-low", "Grace period (too low)", self.grace["low"], 0, 5, 0.001, ), Slider( "warn-on-clip", "Warn on clip?", int(self.warn_on_clip), 0, 1, 1 ), Slider( "msg-duration-high", "Message duration (high)", self.msg_duration["high"], 0, 10, 0.1, ), Slider( "msg-duration-low", "Message duration (low)", self.msg_duration["low"], 0, 10, 0.1, ), ] ) else: self.slider = None display_range = {"min": -60, "max": 0} decay = {"display": 0.1, "high": 0.1, "low": 0.1} threshold = {"high": -2, "low": -20} grace = {"high": 0.0, "low": 1.5} warn_on_clip = True msg_duration = {"high": 0.25, "low": 0.25} def to_json(self): return Markup( json.dumps( { "display_range": self.display_range, "decay": self.decay, "threshold": self.threshold, "grace": self.grace, "warn_on_clip": self.warn_on_clip, "msg_duration": self.msg_duration, } ) ) def update_events(self, events): events["audioMeterMinimalTime"] = Event( is_triggered_by="trialStart", delay=self.min_time ) events["submitEnable"].add_trigger("audioMeterMinimalTime")
[docs] def get_bot_response(self, experiment, bot, page, prompt): return None
[docs] class TappingAudioMeterControl(AudioMeterControl): decay = {"display": 0.01, "high": 0, "low": 0.01} threshold = {"high": -2, "low": -20} grace = {"high": 0.2, "low": 1.5} warn_on_clip = False msg_duration = {"high": 0.25, "low": 0.25}
[docs] class SliderControl(Control): """ This control interface displays either a horizontal or circular slider to the participant. The control logs all interactions from the participant including: - initial location of the slider - subsequent release points along with time stamps Currently the slider does not display any numbers describing the slider's current position. We anticipate adding this feature in a future release, if there is interest. Parameters ---------- label: Internal label for the control (used to store results). start_value: Initial position of slider. min_value: Minimum value of the slider. max_value: Maximum value of the slider. n_steps: Determines the number of steps that the slider can be dragged through. Default: `10000`. snap_values: Optional. Determines the values to which the slider will 'snap' to once it is released. Can take various forms: - ``<None>``: no snapping is performed. - ``<int>``: indicating number of equidistant steps between `min_value` and `max_value`. - ``<list>``: list of numbers enumerating all possible values, need to be within `min_value` and `max_value`. reverse_scale: Flip the scale. Default: `False`. directional: default: True Make the slider appear in either grey/blue color (directional) or all grey color (non-directional). slider_id: The HTML id attribute value of the slider. Default: `"sliderpage_slider"`. input_type: Defaults to `"HTML5_range_slider"`, which gives a standard horizontal slider. The other option currently is `"circular_slider"`, which gives a circular slider. random_wrap: Defaults to `False`. If `True` then slider is wrapped twice so that there are no boundary jumps, and the phase to initialize the wrapping is randomized each time. minimal_interactions: Minimal interactions with the slider before the user can go to the next trial. Default: `0`. minimal_time: Minimum amount of time in seconds that the user must spend on the page before they can continue. Default: `0`. continuous_updates: If `True`, then the slider continuously calls slider-update events when it is dragged, rather than just when it is released. In this case the log is disabled. Default: `False`. template_filename: Filename of an optional additional template. Default: `None`. template_args: Arguments for the optional additional template. Default: `None`. """ def __init__( self, start_value: float, min_value: float, max_value: float, n_steps: int = 10000, reverse_scale: Optional[bool] = False, directional: Optional[bool] = True, slider_id: Optional[str] = "sliderpage_slider", input_type: Optional[str] = "HTML5_range_slider", random_wrap: Optional[bool] = False, snap_values: Optional[Union[int, list]] = None, minimal_interactions: Optional[int] = 0, minimal_time: Optional[int] = 0, continuous_updates: Optional[bool] = False, template_filename: Optional[str] = None, template_args: Optional[Dict] = None, bot_response=NoArgumentProvided, ): super().__init__(bot_response) if snap_values is not None and input_type == "circular_slider": raise ValueError( "Snapping values is currently not supported for circular sliders, set snap_values=None" ) if input_type == "circular_slider" and reverse_scale: raise NotImplementedError( "Reverse scale is currently not supported for circular sliders, set reverse_scale=False" ) self.start_value = start_value self.min_value = min_value self.max_value = max_value self.n_steps = n_steps self.step_size = (max_value - min_value) / (n_steps - 1) self.reverse_scale = reverse_scale self.directional = directional self.slider_id = slider_id self.input_type = input_type self.random_wrap = random_wrap self.template_filename = template_filename self.template_args = template_args self.minimal_time = minimal_time self.snap_values = self.format_snap_values( snap_values, min_value, max_value, n_steps ) js_vars = {} js_vars["snap_values"] = self.snap_values js_vars["minimal_interactions"] = minimal_interactions js_vars["continuous_updates"] = continuous_updates self.js_vars = js_vars macro = "slider" def format_snap_values(self, snap_values, min_value, max_value, n_steps): if snap_values is None: return snap_values # return linspace(min_value, max_value, n_steps) elif isinstance(snap_values, int): return linspace(min_value, max_value, snap_values) else: for x in snap_values: assert isinstance(x, (float, int)) assert x >= min_value assert x <= max_value return sorted(snap_values)
[docs] def validate(self, response, **kwargs): if self.max_value <= self.min_value: raise ValueError("`max_value` must be larger than `min_value`") if self.start_value > self.max_value or self.start_value < self.min_value: raise ValueError( "`start_value` (= %f) must be between `min_value` (=%f) and `max_value` (=%f)" % (self.start_value, self.min_value, self.max_value) ) if self.js_vars["minimal_interactions"] < 0: raise ValueError("`minimal_interactions` cannot be negative!")
@property def metadata(self): return { "start_value": self.start_value, "min_value": self.min_value, "max_value": self.max_value, "n_steps": self.n_steps, "step_size": self.step_size, "reverse_scale": self.reverse_scale, "directional": self.directional, "slider_id": self.slider_id, "input_type": self.input_type, "random_wrap": self.random_wrap, "template_filename": self.template_filename, "template_args": self.template_args, "js_vars": self.js_vars, } def update_events(self, events): events["sliderMinimalTime"] = Event( is_triggered_by="trialStart", delay=self.minimal_time ) events["submitEnable"].add_triggers( "sliderMinimalInteractions", "sliderMinimalTime" )
[docs] def get_bot_response(self, experiment, bot, page, prompt): import numpy as np equidistant = not isinstance(self.snap_values, list) if equidistant: if self.snap_values: n_candidates = self.snap_values else: n_candidates = self.n_steps candidates = list( np.linspace(self.min_value, self.max_value, num=n_candidates) ) else: candidates = self.snap_values return random.sample(candidates, 1)[0]
EXTENSIONS = { "audio": ["wav", "mp3"], "image": ["jpg", "jpeg", "png", "gif", "svg"], "html": ["svg", "txt"], "video": ["mp4", "ogg"], }
[docs] class MediaSliderControl(SliderControl): """ This control solicits a slider response from the user that results in playing some media. The slider can either be horizontal or circular. Parameters ---------- start_value: Initial position of slider. min_value: Minimum value of the slider. max_value: Maximum value of the slider. slider_media: A dictionary of media assets (image, video, or sound). Each item can either be a string, corresponding to the URL for a single file (e.g. "/static/audio/test.wav"), or a dictionary, corresponding to metadata for a batch of media assets. A batch dictionary must contain the field "url", providing the URL to the batch file, and the field "ids", providing the list of IDs for the batch's constituent assets. A valid audio argument might look like the following: :: { 'example': '/static/example.wav', 'my_batch': { 'url': '/static/file_concatenated.mp3', 'ids': ['funk_game_loop', 'honey_bee', 'there_it_is'], 'type': 'batch' } } modality: Either ``"audio"``, ``"image"`` or ``"video"``. media_locations: Dictionary with IDs as keys and locations on the slider as values. autoplay: The media closest to the current slider position is shown once the page is loaded. Default: `False`. disable_while_playing : bool If `True`, the slider is disabled while the media is playing. Default: `False`. .. deprecated:: 11.0.0 Use ``disable_slider_on_change`` instead. disable_slider_on_change: - ``<float>``: Duration for which the media slider should be disabled after its value changed, in seconds. - ``"while_playing"``: The slider will be disabled after a value change, as long as the related media is playing. - ``"never"``: The slider will not be disabled after a value change. Default: `never`. n_steps: - ``<int>``: Number of equidistant steps between `min_value` and `max_value` that the slider can be dragged through. This is before any snapping occurs. - ``"n_media"``: Sets the number of steps to the number of media. This only makes sense if the media locations are distributed equidistant between the `min_value` and `max_value` of the slider. Default: `10000`. slider_id: The HTML id attribute value of the slider. Default: `"sliderpage_slider"`. input_type: Defaults to `"HTML5_range_slider"`, which gives a standard horizontal slider. The other option currently is `"circular_slider"`, which gives a circular slider. random_wrap: Defaults to `False`. If `True` then original value of the slider is wrapped twice, creating a new virtual range between min and min+2(max-min). To avoid boundary issues, the phase of the slider is randomised for each slider using the new range. During the user interaction with the slider, we use the virtual wrapped value (`output_value`) in the new range and with the random phase, but at the end we use the unwrapped value in the original range and without random phase (`raw_value`). Both values are stored in the metadata. reverse_scale: Flip the scale. Default: `False`. directional: default: True Make the slider appear in either grey/blue color (directional) or all grey color (non-directional). snap_values: - ``"media_locations"``: slider snaps to nearest media location. - ``<int>``: indicates number of possible equidistant steps between `min_value` and `max_value` - ``<list>``: enumerates all possible values, need to be within `min_value` and `max_value`. - ``None``: don't snap slider. Default: `"media_locations"`. minimal_interactions: Minimal interactions with the slider before the user can go to the next trial. Default: `0`. minimal_time: Minimum amount of time in seconds that the user must spend on the page before they can continue. Default: `0`. continuous_updates: If `True`, then the slider continuously calls slider-update events when it is dragged, rather than just when it is released. In this case the log is disabled. Default: `False`. """ def __init__( self, start_value: float, min_value: float, max_value: float, slider_media: dict, modality: str, media_locations: dict, autoplay: Optional[bool] = False, disable_while_playing: Optional[bool] = False, disable_slider_on_change: Optional[Union[float, str]] = "", n_steps: Optional[int] = 10000, slider_id: Optional[str] = "sliderpage_slider", input_type: Optional[str] = "HTML5_range_slider", random_wrap: Optional[bool] = False, reverse_scale: Optional[bool] = False, directional: bool = True, snap_values: Optional[Union[int, list]] = "media_locations", minimal_time: Optional[int] = 0, minimal_interactions: Optional[int] = 0, ): if modality not in EXTENSIONS.keys(): raise NotImplementedError(f"Modality not implemented: {modality}") if isinstance(n_steps, str): if n_steps == "n_media": n_steps = len(media_locations) else: raise ValueError(f"Invalid value of n_steps: {n_steps}") if isinstance(snap_values, str): if snap_values == "media_locations": snap_values = list(media_locations.values()) else: raise ValueError(f"Invalid value of snap_values: {snap_values}") # Check if all stimuli specified in `media_locations` are # also preloaded before the participant can start the trial IDs_media_locations = [ID for ID, _ in media_locations.items()] IDs_media = [] for key, value in slider_media.items(): if isinstance(slider_media[key], dict) and "ids" in slider_media[key]: IDs_media.append(slider_media[key]["ids"]) elif isinstance(slider_media[key], str): assert any( [value.lower().endswith(ext) for ext in EXTENSIONS[modality]] ), f"Unsupported file extension: {value} (available extensions for {modality}: {EXTENSIONS[modality]})" IDs_media.append(key) else: raise NotImplementedError( "Currently we only support batch files or single files" ) IDs_media = list(itertools.chain.from_iterable(IDs_media)) if not any([i in IDs_media for i in IDs_media_locations]): raise ValueError( "All stimulus IDs you specify in `media_locations` need to be defined in `media` too." ) if disable_while_playing: warnings.warn( "disable_while_playing is deprecated, please migrate to disable_slider_on_change.", DeprecationWarning, ) super().__init__( start_value=start_value, min_value=min_value, max_value=max_value, n_steps=n_steps, slider_id=slider_id, input_type=input_type, random_wrap=random_wrap, reverse_scale=reverse_scale, directional=directional, snap_values=snap_values, minimal_interactions=minimal_interactions, minimal_time=minimal_time, ) self.media_locations = media_locations self.modality = modality self.autoplay = autoplay self.disable_while_playing = disable_while_playing self.disable_slider_on_change = disable_slider_on_change self.snap_values = snap_values self.slider_media = slider_media self.js_vars["modality"] = modality self.js_vars["media_locations"] = media_locations self.js_vars["autoplay"] = autoplay self.js_vars["disable_while_playing"] = ( True if ( disable_while_playing is True or type(disable_slider_on_change) is int or type(disable_slider_on_change) is float or disable_slider_on_change == "while_playing" ) else False ) self.js_vars["disable_duration"] = ( disable_slider_on_change if ( type(disable_slider_on_change) is int or type(disable_slider_on_change) is float ) else 0 ) macro = "media_slider" @property def metadata(self): return { **super().metadata, "media_locations": self.media_locations, "modality": self.modality, "autoplay": self.autoplay, "disable_while_playing": self.disable_while_playing, "disable_slider_on_change": self.disable_slider_on_change, }
[docs] class AudioSliderControl(MediaSliderControl): """ Audio slider control for backwards compatibility with `AudioSliderControl`. """ def __init__( self, start_value: float, min_value: float, max_value: float, audio: dict, sound_locations: dict, autoplay: Optional[bool] = False, disable_while_playing: Optional[bool] = False, disable_slider_on_change: Optional[Union[float, str]] = "", n_steps: Optional[int] = 10000, slider_id: Optional[str] = "sliderpage_slider", input_type: Optional[str] = "HTML5_range_slider", random_wrap: Optional[bool] = False, reverse_scale: Optional[bool] = False, directional: bool = True, snap_values: Optional[Union[int, list]] = "sound_locations", minimal_interactions: Optional[int] = 0, minimal_time: Optional[int] = 0, ): if snap_values == "sound_locations": snap_values = "media_locations" if n_steps in ["n_sounds", "num_sounds"]: n_steps = "n_media" super().__init__( start_value=start_value, min_value=min_value, max_value=max_value, slider_media=audio, modality="audio", media_locations=sound_locations, autoplay=autoplay, disable_while_playing=disable_while_playing, disable_slider_on_change=disable_slider_on_change, n_steps=n_steps, slider_id=slider_id, input_type=input_type, random_wrap=random_wrap, reverse_scale=reverse_scale, directional=directional, snap_values=snap_values, minimal_interactions=minimal_interactions, minimal_time=minimal_time, ) macro = "audio_media_slider" @property def metadata(self): return { **super().metadata, "sound_locations": self.media_locations, "autoplay": self.autoplay, }
[docs] class ImageSliderControl(MediaSliderControl): """ This control solicits a slider response from the user that results in showing an image. The slider can either be horizontal or circular. Parameters ---------- start_value: Initial position of slider. min_value: Minimum value of the slider. max_value: Maximum value of the slider. slider_media: A dictionary of media assets (image). Each item can either be a string, corresponding to the URL for a single file (e.g. "/static/image/test.png"), or a dictionary, corresponding to metadata for a batch of media assets. A batch dictionary must contain the field "url", providing the URL to the batch file, and the field "ids", providing the list of IDs for the batch's constituent assets. A valid image argument might look like the following: :: { 'example': '/static/example.png', 'my_batch': { 'url': '/static/file_concatenated.batch', 'ids': ['funk_game_loop', 'honey_bee', 'there_it_is'], 'type': 'batch' } } media_locations: Dictionary with IDs as keys and locations on the slider as values. autoplay: The media closest to the current slider position is shown once the page is loaded. Default: `False`. disable_slider_on_change: - ``<float>``: Duration for which the media slider should be disabled after its value changed, in seconds. - ``"while_playing"``: The slider will be disabled after a value change, as long as the related media is playing. - ``"never"``: The slider will not be disabled after a value change. Default: `never`. media_width: CSS width specification for the media container. The image will scale to the width of this container. media_height: CSS height specification for the media container. n_steps: - ``<int>``: Number of equidistant steps between `min_value` and `max_value` that the slider can be dragged through. This is before any snapping occurs. - ``"n_media"``: Sets the number of steps to the number of media. This only makes sense if the media locations are distributed equidistant between the `min_value` and `max_value` of the slider. Default: `10000`. slider_id: The HTML id attribute value of the slider. Default: `"sliderpage_slider"`. input_type: Defaults to `"HTML5_range_slider"`, which gives a standard horizontal slider. The other option currently is `"circular_slider"`, which gives a circular slider. random_wrap: Defaults to `False`. If `True` then original value of the slider is wrapped twice, creating a new virtual range between min and min+2(max-min). To avoid boundary issues, the phase of the slider is randomised for each slider using the new range. During the user interaction with the slider, we use the virtual wrapped value (`output_value`) in the new range and with the random phase, but at the end we use the unwrapped value in the original range and without random phase (`raw_value`). Both values are stored in the metadata. reverse_scale: Flip the scale. Default: `False`. directional: default: True Make the slider appear in either grey/blue color (directional) or all grey color (non-directional). snap_values: - ``"media_locations"``: slider snaps to nearest image location. - ``<int>``: indicates number of possible equidistant steps between `min_value` and `max_value` - ``<list>``: enumerates all possible values, need to be within `min_value` and `max_value`. - ``None``: don't snap slider. Default: `"media_locations"`. minimal_interactions: Minimal interactions with the slider before the user can go to the next trial. Default: `0`. minimal_time: Minimum amount of time in seconds that the user must spend on the page before they can continue. Default: `0`. continuous_updates: If `True`, then the slider continuously calls slider-update events when it is dragged, rather than just when it is released. In this case the log is disabled. Default: `False`. """ def __init__( self, start_value: float, min_value: float, max_value: float, slider_media: dict, media_locations: dict, autoplay: Optional[bool] = False, disable_slider_on_change: Optional[Union[float, str]] = "", media_width: Optional[str] = "", media_height: Optional[str] = "", n_steps: Optional[int] = 10000, slider_id: Optional[str] = "sliderpage_slider", input_type: Optional[str] = "HTML5_range_slider", random_wrap: Optional[bool] = False, reverse_scale: Optional[bool] = False, directional: bool = True, snap_values: Optional[Union[int, list]] = "media_locations", minimal_time: Optional[int] = 0, minimal_interactions: Optional[int] = 0, continuous_updates: Optional[bool] = False, ): super().__init__( start_value=start_value, min_value=min_value, max_value=max_value, slider_media=slider_media, modality="image", media_locations=media_locations, autoplay=autoplay, disable_slider_on_change=disable_slider_on_change, n_steps=n_steps, slider_id=slider_id, input_type=input_type, random_wrap=random_wrap, reverse_scale=reverse_scale, directional=directional, snap_values=snap_values, minimal_time=minimal_time, minimal_interactions=minimal_interactions, ) self.media_width = media_width self.media_height = media_height self.continuous_updates = continuous_updates self.js_vars["continuous_updates"] = continuous_updates macro = "image_media_slider" @property def metadata(self): return { **super().metadata, "continuous_updates": self.continuous_updates, }
[docs] class HtmlSliderControl(MediaSliderControl): """ This control solicits a slider response from the user that results in showing an HTML element. The slider can either be horizontal or circular. Parameters ---------- start_value: Initial position of slider. min_value: Minimum value of the slider. max_value: Maximum value of the slider. slider_media: A dictionary of media assets (image). Each item can either be a string, corresponding to the URL for a single file (e.g. "/static/image/test.svg"), or a dictionary, corresponding to metadata for a batch of media assets. A batch dictionary must contain the field "url", providing the URL to the batch file, and the field "ids", providing the list of IDs for the batch's constituent assets. A valid image argument might look like the following: :: { 'example': '/static/example.svg', 'my_batch': { 'url': '/static/file_concatenated.batch', 'ids': ['funk_game_loop', 'honey_bee', 'there_it_is'], 'type': 'batch' } } media_locations: Dictionary with IDs as keys and locations on the slider as values. autoplay: The media closest to the current slider position is shown once the page is loaded. Default: `False`. disable_slider_on_change: - ``<float>``: Duration for which the media slider should be disabled after its value changed, in seconds. - ``"while_playing"``: The slider will be disabled after a value change, as long as the related media is playing. - ``"never"``: The slider will not be disabled after a value change. Default: `never`. media_width: CSS width specification for the media container. media_height: CSS height specification for the media container. n_steps: - ``<int>``: Number of equidistant steps between `min_value` and `max_value` that the slider can be dragged through. This is before any snapping occurs. - ``"n_media"``: Sets the number of steps to the number of media. This only makes sense if the media locations are distributed equidistant between the `min_value` and `max_value` of the slider. Default: `10000`. slider_id: The HTML id attribute value of the slider. Default: `"sliderpage_slider"`. input_type: Defaults to `"HTML5_range_slider"`, which gives a standard horizontal slider. The other option currently is `"circular_slider"`, which gives a circular slider. random_wrap: Defaults to `False`. If `True` then original value of the slider is wrapped twice, creating a new virtual range between min and min+2(max-min). To avoid boundary issues, the phase of the slider is randomised for each slider using the new range. During the user interaction with the slider, we use the virtual wrapped value (`output_value`) in the new range and with the random phase, but at the end we use the unwrapped value in the original range and without random phase (`raw_value`). Both values are stored in the metadata. reverse_scale: Flip the scale. Default: `False`. directional: default: True Make the slider appear in either grey/blue color (directional) or all grey color (non-directional). snap_values: - ``"media_locations"``: slider snaps to nearest image location. - ``<int>``: indicates number of possible equidistant steps between `min_value` and `max_value` - ``<list>``: enumerates all possible values, need to be within `min_value` and `max_value`. - ``None``: don't snap slider. Default: `"media_locations"`. minimal_interactions: Minimal interactions with the slider before the user can go to the next trial. Default: `0`. minimal_time: Minimum amount of time in seconds that the user must spend on the page before they can continue. Default: `0`. continuous_updates: If `True`, then the slider continuously calls slider-update events when it is dragged, rather than just when it is released. In this case the log is disabled. Default: `False`. """ def __init__( self, start_value: float, min_value: float, max_value: float, slider_media: dict, media_locations: dict, autoplay: Optional[bool] = False, disable_slider_on_change: Optional[Union[float, str]] = "", media_width: Optional[str] = "", media_height: Optional[str] = "", n_steps: Optional[int] = 10000, slider_id: Optional[str] = "sliderpage_slider", input_type: Optional[str] = "HTML5_range_slider", random_wrap: Optional[bool] = False, reverse_scale: Optional[bool] = False, directional: bool = True, snap_values: Optional[Union[int, list]] = "media_locations", minimal_time: Optional[int] = 0, minimal_interactions: Optional[int] = 0, continuous_updates: Optional[bool] = False, ): super().__init__( start_value=start_value, min_value=min_value, max_value=max_value, slider_media=slider_media, modality="image", media_locations=media_locations, autoplay=autoplay, disable_slider_on_change=disable_slider_on_change, n_steps=n_steps, slider_id=slider_id, input_type=input_type, random_wrap=random_wrap, reverse_scale=reverse_scale, directional=directional, snap_values=snap_values, minimal_time=minimal_time, minimal_interactions=minimal_interactions, ) self.media_width = media_width self.media_height = media_height self.continuous_updates = continuous_updates self.js_vars["continuous_updates"] = continuous_updates macro = "html_media_slider" @property def metadata(self): return { **super().metadata, "continuous_updates": self.continuous_updates, }
[docs] class VideoSliderControl(MediaSliderControl): """ This control solicits a slider response from the user that results in showing a video. The slider can either be horizontal or circular. Parameters ---------- start_value: Initial position of slider. min_value: Minimum value of the slider. max_value: Maximum value of the slider. slider_media: A dictionary of media assets (video). Each item can either be a string, corresponding to the URL for a single file (e.g. "/static/image/test.mp4"), or a dictionary, corresponding to metadata for a batch of media assets. A batch dictionary must contain the field "url", providing the URL to the batch file, and the field "ids", providing the list of IDs for the batch's constituent assets. A valid image argument might look like the following: :: { 'example': '/static/example.mp4', 'my_batch': { 'url': '/static/file_concatenated.mp4', 'ids': ['funk_game_loop', 'honey_bee', 'there_it_is'], 'type': 'batch' } } media_locations: Dictionary with IDs as keys and locations on the slider as values. autoplay: The media closest to the current slider position is shown once the page is loaded. Default: `False`. disable_while_playing : bool If `True`, the slider is disabled while the media is playing. Default: `False`. .. deprecated:: 11.0.0 Use ``disable_slider_on_change`` instead. disable_slider_on_change: - ``<float>``: Duration for which the media slider should be disabled after its value changed, in seconds. - ``"while_playing"``: The slider will be disabled after a value change, as long as the related media is playing. - ``"never"``: The slider will not be disabled after a value change. Default: `never`. media_width: CSS width specification for the media container. The video will scale to the width of this container. media_height: CSS height specification for the media container. n_steps: - ``<int>``: Number of equidistant steps between `min_value` and `max_value` that the slider can be dragged through. This is before any snapping occurs. - ``"n_media"``: Sets the number of steps to the number of media. This only makes sense if the media locations are distributed equidistant between the `min_value` and `max_value` of the slider. Default: `10000`. slider_id: The HTML id attribute value of the slider. Default: `"sliderpage_slider"`. input_type: Defaults to `"HTML5_range_slider"`, which gives a standard horizontal slider. The other option currently is `"circular_slider"`, which gives a circular slider. random_wrap: Defaults to `False`. If `True` then original value of the slider is wrapped twice, creating a new virtual range between min and min+2(max-min). To avoid boundary issues, the phase of the slider is randomised for each slider using the new range. During the user interaction with the slider, we use the virtual wrapped value (`output_value`) in the new range and with the random phase, but at the end we use the unwrapped value in the original range and without random phase (`raw_value`). Both values are stored in the metadata. reverse_scale: Flip the scale. Default: `False`. directional: default: True Make the slider appear in either grey/blue color (directional) or all grey color (non-directional). snap_values: - ``"media_locations"``: slider snaps to nearest video location. - ``<int>``: indicates number of possible equidistant steps between `min_value` and `max_value` - ``<list>``: enumerates all possible values, need to be within `min_value` and `max_value`. - ``None``: don't snap slider. Default: `"media_locations"`. minimal_interactions: Minimal interactions with the slider before the user can go to the next trial. Default: `0`. minimal_time: Minimum amount of time in seconds that the user must spend on the page before they can continue. Default: `0`. """ def __init__( self, start_value: float, min_value: float, max_value: float, slider_media: dict, media_locations: dict, autoplay: Optional[bool] = False, disable_while_playing: Optional[bool] = False, disable_slider_on_change: Optional[Union[float, str]] = "never", media_width: Optional[str] = "", media_height: Optional[str] = "", n_steps: Optional[int] = 10000, slider_id: Optional[str] = "sliderpage_slider", input_type: Optional[str] = "HTML5_range_slider", random_wrap: Optional[bool] = False, reverse_scale: Optional[bool] = False, directional: bool = True, snap_values: Optional[Union[int, list]] = "media_locations", minimal_time: Optional[int] = 0, minimal_interactions: Optional[int] = 0, **kwargs, ): if "url" in kwargs or "file_type" in kwargs: raise ValueError( "VideoSliderControl has now been replaced with FrameSliderControl when it concerns sliding through the frames of a single video," " please use the latter now. In case you want to slide through a series of videos, you can use VideoSliderControl. In that case, " "please specify slider_media and media_locations rather than url or file_type.", ) super().__init__( start_value=start_value, min_value=min_value, max_value=max_value, slider_media=slider_media, modality="video", media_locations=media_locations, autoplay=autoplay, disable_while_playing=disable_while_playing, disable_slider_on_change=disable_slider_on_change, n_steps=n_steps, slider_id=slider_id, input_type=input_type, random_wrap=random_wrap, reverse_scale=reverse_scale, directional=directional, snap_values=snap_values, minimal_time=minimal_time, minimal_interactions=minimal_interactions, ) self.media_width = media_width self.media_height = media_height macro = "video_media_slider" @property def metadata(self): return { **super().metadata, "media_locations": self.media_locations, "modality": self.modality, "autoplay": self.autoplay, }
# WIP
[docs] class ColorSliderControl(SliderControl): def __init__( self, start_value: float, min_value: float, max_value: float, slider_id: Optional[str] = "sliderpage_slider", hidden_inputs: Optional[dict] = {}, ): super().__init__( start_value=start_value, min_value=min_value, max_value=max_value, slider_id=slider_id, hidden_inputs=hidden_inputs, ) macro = "color_slider" @property def metadata(self): return { **super().metadata, "hidden_inputs": self.hidden_inputs, }
# WIP
[docs] class MultiSliderControl(Control): def __init__( self, sliders, next_button=True, bot_response=NoArgumentProvided, ): super().__init__(bot_response) assert is_list_of(sliders, Slider) self.sliders = sliders self.next_button = next_button
class Slider: def __init__(self, slider_id, label, start_value, min_value, max_value, step_size): self.label = label self.start_value = start_value self.min_value = min_value self.max_value = max_value self.step_size = step_size self.slider_id = slider_id
[docs] class RecordControl(Control): """ Generic class for recording controls. Cannot be instantiated directly. See :class:`~psynet.modular_page.AudioRecordControl` and :class:`~psynet.modular_page.VideoRecordControl`. duration Duration of the desired recording, in seconds. Note: the output recording may not be exactly this length, owing to inaccuracies in the Javascript recording process. auto_advance Whether the page should automatically advance to the next page once the audio recording has been uploaded. show_meter Whether an audio meter should be displayed, so as to help the participant to calibrate their volume. """ file_extension = None def __init__( self, duration: float, auto_advance: bool = False, show_meter: bool = False, bot_response=NoArgumentProvided, **kwargs, ): if "s3_bucket" in kwargs or "public_read" in kwargs: raise ValueError( "s3_bucket and public_read arguments have been removed from RecordControl classes, ", "please delete them from your implementation. Your S3 bucket is now determined by your " "S3Storage object, for example when you set asset_storage = S3Storage('my-bucket', 'my-root') " "within your Experiment class.", ) for arg in kwargs: raise ValueError(f"Unexpected argument: {arg}") super().__init__(bot_response) self.duration = duration self.auto_advance = auto_advance if show_meter: self.meter = AudioMeterControl(show_next_button=False) else: self.meter = None @property def metadata(self): return {} def update_events(self, events): events["recordStart"] = Event(Trigger("responseEnable")) events["recordEnd"] = Event(Trigger("recordStart", delay=self.duration)) events["submitEnable"].add_triggers("recordEnd") if self.auto_advance: events["autoSubmit"] = Event(is_triggered_by="submitEnable") # events["uploadEnd"] = Event(is_triggered_by=[]) def get_bot_response_files(self, experiment, bot, page, prompt): if self.bot_response_media is None: self.raise_bot_response_not_provided_error() elif callable(self.bot_response_media): return call_function( self.bot_response_media, bot=bot, experiment=experiment, page=page, prompt=prompt, ) else: return self.bot_response_media def raise_bot_response_not_provided_error(self): raise NotImplementedError
[docs] class AudioRecordControl(RecordControl): """ Records audio from a participant. Parameters ---------- controls Whether to give the user controls for the recorder (default = ``False``). loop_playback Whether in-browser playback of the recording should have looping enabled by default (default = ``False``). Ignored if ``controls`` is ``False``. num_channels The number of channels used to record the audio. Default is mono (`num_channels=1`). personal Whether the recording should be marked as 'personal' and hence excluded from 'scrubbed' data exports. Default: `True`. **kwargs Further arguments passed to :class:`~psynet.modular_page.RecordControl` """ macro = "audio_record" file_extension = ".wav" def __init__( self, *, controls: bool = False, loop_playback: bool = False, num_channels: int = 1, personal=True, bot_response_media: Optional[Union[dict, str]] = None, **kwargs, ): super().__init__(**kwargs) self.controls = controls self.loop_playback = loop_playback self.num_channels = num_channels self.personal = personal self.bot_response_media = bot_response_media
[docs] def format_answer(self, raw_answer, **kwargs): blobs = kwargs["blobs"] audio = blobs["audioRecording"] trial = kwargs["trial"] participant = kwargs["participant"] if trial: parent = trial else: parent = participant # Need to leave file deletion to the depositing process # if we're going to run it asynchronously with tempfile.NamedTemporaryFile(delete=False) as tmp_file: audio.save(tmp_file.name) from .trial.record import Recording label = self.page.label asset = Recording( local_key=label, input_path=tmp_file.name, extension=self.file_extension, parent=parent, personal=self.personal, ) async_ = not isinstance(asset.default_storage, LocalStorage) asset.deposit(async_=async_, delete_input=True) return { "origin": "AudioRecordControl", "supports_record_trial": True, "asset_id": asset.id, "url": asset.url, "duration_sec": self.duration, # TODO - base this on the actual audio file? }
def visualize_response(self, answer, response, trial): if answer is None: return tags.p("No audio recorded yet.").render() else: return tags.audio( tags.source(src=answer["url"]), id="visualize-audio-response", controls=True, ).render() def update_events(self, events): super().update_events(events) events["trialFinish"].add_trigger("recordEnd")
[docs] def get_bot_response(self, experiment, bot, page, prompt): from .bot import BotResponse file = self.get_bot_response_files(experiment, bot, page, prompt) return BotResponse( raw_answer=None, blobs={"audioRecording": Blob(file)}, )
def raise_bot_response_not_provided_error(self): raise NotImplementedError( "To use an AudioRecordControl with bots, you should set the bot_response_media argument " "to provide a path to an audio file that the bot should 'return'. " "This can be provided as a string, or alternatively as a function that returns a string, " "taking (optionally) any of the following arguments: bot, experiment, page, prompt." )
[docs] class VideoRecordControl(RecordControl): """ Records a video either by using the camera or by capturing from the screen. Output format for both screen and camera recording is ``.webm``. Parameters ---------- recording_source Specifies whether to record by using the camera and/or by capturing from the screen. Possible values are 'camera', 'screen' and 'both'. Default: 'camera'. record_audio Whether to record audio using the microphone. This setting only applies when 'camera' or 'both' is chosen as `recording_source`. Default: `True`. audio_n_channels The number of channels used to record the audio (if enabled by `record_audio`). Default is mono (`audio_n_channels=1`). width Width of the video frame to be displayed. Default: "560px". show_preview Whether to show a preview of the video on the page. Default: `False`. controls Whether to provide controls for manipulating the recording. loop_playback Whether to loop playback by default (only relevant if ``controls=True``. mirrored Whether the preview of the video is displayed as if looking into a mirror. Default: `True`. personal Whether the recording should be marked as 'personal' and hence excluded from 'scrubbed' data exports. Default: `True`. """ macro = "video_record" file_extension = ".webm" def __init__( self, *, recording_source: str = "camera", record_audio: bool = True, audio_n_channels: int = 1, width: str = "300px", show_preview: bool = False, controls: bool = False, loop_playback: bool = False, mirrored: bool = True, personal: bool = True, bot_response_media: Optional[str] = None, **kwargs, ): super().__init__(**kwargs) self.recording_source = recording_source self.record_audio = record_audio self.audio_n_channels = audio_n_channels self.width = width self.show_preview = show_preview self.controls = controls self.loop_playback = loop_playback self.mirrored = mirrored self.personal = personal self.bot_response_media = bot_response_media if self.record_audio is False: self.audio_n_channels = 0 assert self.recording_source in ["camera", "screen", "both"] @property def recording_sources(self): return dict(camera=["camera"], screen=["screen"], both=["camera", "screen"])[ self.recording_source ]
[docs] def format_answer(self, raw_answer, **kwargs): blobs = kwargs["blobs"] trial = kwargs["trial"] participant = kwargs["participant"] if trial: parent = trial else: parent = participant summary = {} for source in self.recording_sources: blob_id = source + "Recording" blob = blobs[blob_id] with tempfile.NamedTemporaryFile(delete=False) as tmp_file: blob.save(tmp_file.name) from .trial.record import Recording label = self.page.label if len(self.recording_sources) > 1: label += "_" + source asset = Recording( local_key=label, input_path=tmp_file.name, extension=self.file_extension, parent=parent, personal=self.personal, ) try: asset.deposit(async_=True, delete_input=True) except Asset.InconsistentContentError: raise ValueError( f"This participant already has an asset with the label '{label}'. " "You should update your VideoRecordControl labels to make them distinct." ) summary[source + "_id"] = asset.id summary[source + "_url"] = asset.url summary.update( { "duration_sec": self.duration, "origin": "VideoRecordControl", "supports_record_trial": True, "recording_source": self.recording_source, "record_audio": self.record_audio, "mirrored": self.mirrored, } ) return summary
def visualize_response(self, answer, response, trial): if answer is None: return tags.p("No video recorded yet.").render() else: html = tags.div() if answer["camera_url"]: html += tags.h5("Camera recording") html += tags.video( tags.source(src=answer["camera_url"]), id="visualize-camera-video-response", controls=True, style="max-width: 640px;", ) if answer["screen_url"]: html += tags.h5("Screen recording") html += tags.video( tags.source(src=answer["screen_url"]), id="visualize-screen-video-response", controls=True, style="max-width: 640px;", ) return html.render() def update_events(self, events): super().update_events(events) events["trialFinish"].add_trigger("recordEnd")
[docs] def get_bot_response(self, experiment, bot, page, prompt): from .bot import BotResponse files = self.get_bot_response_files(experiment, bot, page, prompt) return BotResponse( raw_answer=None, blobs={ f"{key}Recording": Blob(files[key]) for key in self.recording_sources }, )
def get_bot_response_files(self, experiment, bot, page, prompt): files = super().get_bot_response_files(experiment, bot, page, prompt) if isinstance(files, str): assert len(self.recording_sources) == 1 return {self.recording_source: files} elif isinstance(files, dict): assert set(files) == {"camera", "screen"} return files else: raise ValueError(f"Invalid files value: {files}") def raise_bot_response_not_provided_error(self): raise NotImplementedError( "To use an VideoRecordControl with bots, you should set the bot_response_media argument " "to provide a path to an audio file that the bot should 'return'. " "This can be provided as a string, or alternatively as a function that returns a string, " "taking (optionally) any of the following arguments: bot, experiment, page, prompt. " "If the VideoRecordControl is meant to provide both a screen recording and a camera recording, " "you should return a dictionary with two file paths, keyed as 'screen' and 'camera'." )
[docs] class FrameSliderControl(Control): macro = "frame_slider" def __init__( self, *, url: str, file_type: str, width: str, height: str, starting_value: float = 0.5, minimal_time: float = 2.0, reverse_scale: bool = False, directional: bool = True, hide_slider: bool = False, bot_response=NoArgumentProvided, ): super().__init__(bot_response) assert 0 <= starting_value <= 1 self.url = url self.file_type = file_type self.width = width self.height = height self.starting_value = starting_value self.minimal_time = minimal_time self.reverse_scale = reverse_scale self.directional = directional self.hide_slider = hide_slider @property def metadata(self): return { "url": self.url, "starting_value": self.starting_value, "minimal_time": self.minimal_time, "reverse_scale": self.reverse_scale, "directional": self.directional, "hide_slider": self.hide_slider, } @property def media(self): return MediaSpec(video={"slider-video": self.url}) def visualize_response(self, answer, response, trial): html = ( super().visualize_response(answer, response, trial) + "\n" + tags.div( tags.p(f"Answer = {answer}"), tags.video( tags.source(src=self.url), id="visualize-video-slider", controls=True, style="max-width: 400px;", ), ).render() ) return html def update_events(self, events): events["sliderMinimalTime"] = Event( is_triggered_by="trialStart", delay=self.minimal_time ) events["submitEnable"].add_triggers( "sliderMinimalTime", )
[docs] def get_bot_response(self, experiment, bot, page, prompt): return random.uniform(0, 1)
[docs] class SurveyJSControl(Control): """ This control exposes the open-source SurveyJS library. You can use this library to develop sophisticated questionnaires which many different question types. When a SurveyJSControl is included in a PsyNet timeline it produces a single SurveyJS survey. This survey can have multiple questions and indeed multiple pages. Responses to these questions are compiled together as a dictionary and saved as the participant's answer, similar to other controls. The recommended way to design a SurveyJS survey is to use their free Survey Creator tool. This can be accessed from their website: https://surveyjs.io/create-free-survey. You design your survey using the interactive editor. Once you are done, click the "JSON Editor" tab. Copy and paste the provided JSON into the ``design`` argument of your ``SurveyJSControl``. You may need to update a few details to match Python syntax, for example replacing ``true`` with ``True``; your syntax highlighter should flag up points that need updating. That's it! See https://surveyjs.io/ for more details. See the survey_js demo for example usage. Parameters ---------- design : A JSON-style specification for the survey. bot_response : Used for bot simulations; see demos for example usage. """ def __init__( self, design, bot_response=NoArgumentProvided, ): super().__init__(bot_response, show_next_button=False) self.design = design macro = "survey_js"
[docs] def get_bot_response(self, experiment, bot, page, prompt): raise NotImplementedError
[docs] def format_answer(self, raw_answer, **kwargs): return json.loads(raw_answer)
[docs] class MusicNotationPrompt(Prompt): """ Displays music notation using the abcjs library by Paul Rosen and Gregory Dyke. See https://www.abcjs.net/ for information about abcjs. See https://abcnotation.com/ for information about ABC notation. Parameters ---------- content : Content to display, in ABC notation. This will be rendered to an image. See https://www.abcjs.net/abcjs-editor.html for an interactive editor. See https://abcnotation.com/wiki/abc:standard:v2.1 for a detailed definition of ABC notation. text : Text to display above the score. text_align : Alignment instructions for this text. """ def __init__( self, content: str, text: Union[None, str, Markup, dom_tag] = None, text_align: str = "left", ): super().__init__(text=text, text_align=text_align) self.content = content macro = "abc_notation" def update_events(self, events): super().update_events(events) events["promptStart"] = Event( is_triggered_by=[ Trigger( triggering_event="trialStart", delay=0, ) ] )