Skip to content

Experiment actions

experiment.actions.base_action#

BaseAction #

Bases: object

Base class for all experiment actions in the MUSCLE framework.

This class serves as the foundation for various action types that configure frontend components. All action classes (e.g., Score, Playback, Form, etc.) inherit from this base class to ensure consistent behavior and structure.

Key Features: - Provides a standardized way to serialize action data for frontend components - Handles frontend styling configuration - Ensures each action has a unique identifier through the ID class variable - Implements a common interface for action serialization via the action() method

Example
class CustomAction(BaseAction):
    view = "CUSTOM"

    def __init__(self, custom_data, style=None):
        super().__init__(style=style)
        self.custom_data = custom_data
Note

When creating a new action type:

  1. Inherit from BaseAction

  2. Define a unique view class variable

  3. Initialize with required parameters

  4. Override action() method if custom serialization is needed

Source code in experiment/actions/base_action.py
class BaseAction(object):
    """Base class for all experiment actions in the MUSCLE framework.

    This class serves as the foundation for various action types that configure
    frontend components. All action classes (e.g., Score, Playback, Form, etc.)
    inherit from this base class to ensure consistent behavior and structure.

    Key Features:
    - Provides a standardized way to serialize action data for frontend components
    - Handles frontend styling configuration
    - Ensures each action has a unique identifier through the ID class variable
    - Implements a common interface for action serialization via the action() method

    Example:
        ```python
        class CustomAction(BaseAction):
            view = "CUSTOM"

            def __init__(self, custom_data, style=None):
                super().__init__(style=style)
                self.custom_data = custom_data
        ```

    Note:
        When creating a new action type: \n
        1. Inherit from `BaseAction` \n
        2. Define a unique `view` class variable \n
        3. Initialize with required parameters \n
        4. Override `action()` method if custom serialization is needed
    """

    view = "BASE"

    def __init__(self, style: Optional[list[str]] = None):
        """Initialize the base action with optional styling.

        Args:
            style: list of class arguments to set in the frontend
        """
        self.style = self._apply_style(style)

    def _apply_style(self, style: list[str]) -> Optional[FrontendStyle]:
        if style:
            return FrontendStyle(style)

    def action(self) -> dict:
        """Serialize the action configuration for frontend consumption.

        This method creates a standardized dictionary format that frontend
        components expect. All instance variables are included, and the
        action's ID is added to identify the correct frontend component.

        Returns:
            dict: A dictionary containing:
                - All instance variables from __dict__
                - 'view': The action's ID for frontend component mapping
                - 'style': Serialized style configuration if present
        """
        action_dict = self.__dict__
        action_dict['view'] = self.view
        if getattr(self, 'style', None):
            action_dict['style'] = self.style.to_dict()
        return action_dict

__init__(style=None) #

Initialize the base action with optional styling.

Parameters:

Name Type Description Default
style Optional[list[str]]

list of class arguments to set in the frontend

None
Source code in experiment/actions/base_action.py
def __init__(self, style: Optional[list[str]] = None):
    """Initialize the base action with optional styling.

    Args:
        style: list of class arguments to set in the frontend
    """
    self.style = self._apply_style(style)

action() #

Serialize the action configuration for frontend consumption.

This method creates a standardized dictionary format that frontend components expect. All instance variables are included, and the action’s ID is added to identify the correct frontend component.

Returns:

Name Type Description
dict dict

A dictionary containing: - All instance variables from dict - ‘view’: The action’s ID for frontend component mapping - ‘style’: Serialized style configuration if present

Source code in experiment/actions/base_action.py
def action(self) -> dict:
    """Serialize the action configuration for frontend consumption.

    This method creates a standardized dictionary format that frontend
    components expect. All instance variables are included, and the
    action's ID is added to identify the correct frontend component.

    Returns:
        dict: A dictionary containing:
            - All instance variables from __dict__
            - 'view': The action's ID for frontend component mapping
            - 'style': Serialized style configuration if present
    """
    action_dict = self.__dict__
    action_dict['view'] = self.view
    if getattr(self, 'style', None):
        action_dict['style'] = self.style.to_dict()
    return action_dict

experiment.actions.consent#

Consent #

Bases: BaseAction

Handles experiment consent form generation and rendering and provides the consent form data to the frontend.

This class manages the display and processing of informed consent forms for experiments. It can handle consent text from multiple sources (file upload, URL template, or default text) and supports both HTML and Markdown formats.

Parameters:

Name Type Description Default
text File

A Django File object containing the consent form content. If provided, this takes precedence over the URL parameter.

required
title Optional[str]

The heading displayed above the consent form. Defaults to “Informed consent”.

'Informed consent'
confirm Optional[str]

Text for the confirmation button. Defaults to “I agree”.

'I agree'
deny Optional[str]

Text for the rejection button. Defaults to “Stop”.

'Stop'
url Optional[str]

Template path to load consent content if no text file is provided. Supports both HTML (default) and Markdown files.

''
Example
consent = Consent(
    text=File(open("path/to/consent.md")),
    title="Informed consent",
    confirm="I agree",
    deny="Stop",
)
Note
  • The text file is normally uploaded via the admin interface for the experiment, so most of the time (and by default) you will use an experiment’s translated_content.consent file.
  • This component is used in conjunction with the frontend Consent.tsx component
  • HTML templates can use Django template language
  • Markdown files are automatically converted to HTML
  • Priority order for content: 1) uploaded file, 2) template URL, 3) default text
Source code in experiment/actions/consent.py
class Consent(BaseAction):  # pylint: disable=too-few-public-methods
    """Handles experiment consent form generation and rendering and provides the consent form data to the frontend.

    This class manages the display and processing of informed consent forms for experiments.
    It can handle consent text from multiple sources (file upload, URL template, or default text)
    and supports both HTML and Markdown formats.

    Args:
        text (File): A Django File object containing the consent form content. If provided,
            this takes precedence over the URL parameter.
        title (Optional[str]): The heading displayed above the consent form.
            Defaults to "Informed consent".
        confirm (Optional[str]): Text for the confirmation button. Defaults to "I agree".
        deny (Optional[str]): Text for the rejection button. Defaults to "Stop".
        url (Optional[str]): Template path to load consent content if no text file is provided.
            Supports both HTML (default) and Markdown files.

    Example:
        ```python
        consent = Consent(
            text=File(open("path/to/consent.md")),
            title="Informed consent",
            confirm="I agree",
            deny="Stop",
        )
        ```

    Note:
        - The text file is normally uploaded via the admin interface for the experiment, so most of the time (and by default) you will use an experiment's `translated_content.consent` file.
        - This component is used in conjunction with the frontend Consent.tsx component
        - HTML templates can use Django template language
        - Markdown files are automatically converted to HTML
        - Priority order for content: 1) uploaded file, 2) template URL, 3) default text
    """

    # default consent text, that can be used for multiple blocks
    view = "CONSENT"

    default_text = "Lorem ipsum dolor sit amet, nec te atqui scribentur. Diam \
                molestie posidonium te sit, ea sea expetenda suscipiantur \
                contentiones, vix ex maiorum denique! Lorem ipsum dolor sit \
                amet, nec te atqui scribentur. Diam molestie posidonium te sit, \
                ea sea expetenda suscipiantur contentiones, vix ex maiorum \
                denique! Lorem ipsum dolor sit amet, nec te atqui scribentur. \
                Diam molestie posidonium te sit, ea sea expetenda suscipiantur \
                contentiones, vix ex maiorum denique! Lorem ipsum dolor sit \
                amet, nec te atqui scribentur. Diam molestie posidonium te sit, \
                ea sea expetenda suscipiantur contentiones."

    def __init__(
        self, text: File, title: str = "Informed consent", confirm: str = "I agree", deny: str = "Stop", url: str = ""
    ) -> None:
        # Determine which text to use
        if text != "":
            # Uploaded consent via file field: block.consent (High priority)
            with text.open("r") as f:
                dry_text = f.read()
            render_format = get_render_format(text.url)
        elif url != "":
            # Template file via url (Low priority)
            dry_text = render_to_string(url)
            render_format = get_render_format(url)
        else:
            # use default text
            dry_text = self.default_text
            render_format = "HTML"
        # render text fot the consent component
        self.text = render_html_or_markdown(dry_text, render_format)
        self.title = title
        self.confirm = confirm
        self.deny = deny

get_render_format(url) #

Detect markdown file based on file extension

Parameters:

Name Type Description Default
url str

Url of the consent file

required

Returns:

Type Description
Literal['HTML', 'MARKDOWN']

File format of the consent file (HTML or MARKDOWN)

Source code in experiment/actions/consent.py
def get_render_format(url: str) -> Literal["HTML", "MARKDOWN"]:
    """
    Detect markdown file based on file extension

    Args:
        url: Url of the consent file

    Returns:
        File format of the consent file (HTML or MARKDOWN)

    """
    if splitext(url)[1] == ".md":
        return "MARKDOWN"
    return "HTML"

render_html_or_markdown(dry_text, render_format) #

render html or markdown

Parameters:

Name Type Description Default
dry_text str

contents of a markdown or html file

required
render_format Literal['HTML', 'MARKDOWN']

type of contents, either ‘HTML’ or ‘MARKDOWN’

required

Returns:

Type Description
str

Content rendered to html.

Source code in experiment/actions/consent.py
def render_html_or_markdown(dry_text: str, render_format: Literal["HTML", "MARKDOWN"]) -> str:
    """render html or markdown

    Args:
        dry_text (str): contents of a markdown or html file
        render_format (Literal["HTML", "MARKDOWN"]): type of contents, either 'HTML' or 'MARKDOWN'

    Returns:
        Content rendered to html.
    """

    if render_format == "HTML":
        template = Template(dry_text)
        context = Context()
        return template.render(context)
    if render_format == "MARKDOWN":
        return formatter(dry_text, filter_name="markdown")

    raise ValueError("Invalid render format. Must be either 'HTML' or 'MARKDOWN'.")

experiment.actions.explainer#

Explainer #

Bases: BaseAction

Provide data for a explainer that explains the experiment steps

Relates to client component: Explainer.tsx

Explainer view automatically proceeds to the following view after timer (in ms) expires. If timer=None, explainer view will proceed to the next view only after a click of a button. Intro explainers should always have timer=None (i.e. interaction with a browser is required), otherwise the browser will not autoplay the first segment.

Parameters:

Name Type Description Default
instruction str

Instruction for the explainer

required
steps List[Step]

List of steps to explain

required
button_label Optional[str]

Label for the button that proceeds to the next view

"Let's go!"
timer Optional[int]

Timer in ms

None
step_numbers Optional[bool]

Show step numbers

False
Source code in experiment/actions/explainer.py
class Explainer(BaseAction):
    """
    Provide data for a explainer that explains the experiment steps

    Relates to client component: Explainer.tsx

    Explainer view automatically proceeds to the following view after timer (in ms) expires. If timer=None, explainer view will proceed to the next view only after a click of a button. Intro explainers should always have timer=None (i.e. interaction with a browser is required), otherwise the browser will not autoplay the first segment.

    Args:
        instruction (str): Instruction for the explainer
        steps (List[Step]): List of steps to explain
        button_label (Optional[str]): Label for the button that proceeds to the next view
        timer (Optional[int]): Timer in ms
        step_numbers (Optional[bool]): Show step numbers
    """

    view = "EXPLAINER"

    def __init__(
        self,
        instruction: str,
        steps: List[Step],
        button_label="Let's go!",
        timer: Optional[int] = None,
        step_numbers: Optional[bool] = False,
    ):
        self.instruction = instruction
        self.steps = steps
        self.button_label = button_label
        self.timer = timer
        self.step_numbers = step_numbers

    def action(self) -> ExplainerResponse:
        if self.step_numbers:
            serialized_steps = [step.action(index + 1) for index, step in enumerate(self.steps)]
        else:
            serialized_steps = [step.action() for step in self.steps]
        return {
            "view": self.view,
            "instruction": self.instruction,
            "button_label": self.button_label,
            "steps": serialized_steps,
            "timer": self.timer,
        }

Step #

Bases: object

A step in an explainer

Parameters:

Name Type Description Default
description str

Description of the step

required
number Optional[int]

Optional number of the step

None
Source code in experiment/actions/explainer.py
class Step(object):
    """
    A step in an explainer

    Args:
        description (str): Description of the step
        number (Optional[int]): Optional number of the step
    """

    def __init__(self, description: str, number: Optional[int] = None):
        self.description = description
        self.number = number

    def action(self, number=None) -> StepResponse:
        """Create an explainer step, with description and optional number"""
        return {"number": self.number if self.number else number, "description": self.description}

action(number=None) #

Create an explainer step, with description and optional number

Source code in experiment/actions/explainer.py
def action(self, number=None) -> StepResponse:
    """Create an explainer step, with description and optional number"""
    return {"number": self.number if self.number else number, "description": self.description}

experiment.actions.final#

ButtonConfiguration #

Bases: TypedDict

Button configuration for an optional call-to-action button.

Attributes:

Name Type Description
text str

The text displayed on the button.

link str

The URL or path to navigate to when the button is clicked.

Source code in experiment/actions/final.py
class ButtonConfiguration(TypedDict):
    """
    Button configuration for an optional call-to-action button.

    Attributes:
        text (str): The text displayed on the button.
        link (str): The URL or path to navigate to when the button is clicked.
    """

    text: str
    link: str

Final #

Bases: BaseAction

Provide data for a “final” view, typically shown at the end of an experiment or session.

This view displays the participant’s final score and, optionally, their rank or performance category. It can also present navigation elements, such as a “Play again” button or links to other parts of the site. Branding elements like a logo or social sharing options can be included to enhance user engagement. A feedback section may also be provided if feedback_info is supplied.

The returned data aligns with FinalActionResponse, ensuring type consistency and making the structure clear for both developers and documentation readers. It can be consumed by a frontend component (e.g., Final.tsx) to render the final screen.

Parameters:

Name Type Description Default
session Session

The current session object associated with the participant.

required
title str

The title displayed at the top of the final view. Defaults to a localized “Final score”.

gettext_lazy('Final score')
final_text Optional[str]

An optional concluding message (e.g., “Thanks for participating!”).

None
button Optional[ButtonConfiguration]

Optional call-to-action button configuration. For example: {“text”: “Play again”, “link”: “/{experiment_slug}”}.

None
points Optional[str]

The label for the score units (e.g., “points”). Defaults to a localized “points”.

None
rank Optional[str]

The participant’s rank (e.g., “GOLD”). If not provided, no rank is displayed.

None
show_profile_link bool

If True, display a link to the participant’s profile.

False
show_participant_link bool

If True, display a participant-related link or information.

False
show_participant_id_only bool

If True, only the participant ID is shown, without a link.

False
feedback_info Optional[Dict[str, str]]

Optional dictionary containing feedback-related data. For example: {“header”: “Feedback”, “prompt”: “Tell us what you think”, “button_text”: “Submit”}.

None
total_score Optional[float]

Explicit final score. If None, this is derived from the session.

None
logo Optional[LogoConfiguration]

Optional logo configuration for branding. For example: {“image”: “/static/logo.png”, “link”: “https://example.com”}.

None
Note

The action() method returns a FinalActionResponse that can be consumed by the frontend to render the final screen.

Source code in experiment/actions/final.py
class Final(BaseAction):  # pylint: disable=too-few-public-methods
    """
    Provide data for a "final" view, typically shown at the end of an experiment or session.

    This view displays the participant's final score and, optionally, their rank or performance category.
    It can also present navigation elements, such as a "Play again" button or links to other parts of the site.
    Branding elements like a logo or social sharing options can be included to enhance user engagement.
    A feedback section may also be provided if `feedback_info` is supplied.

    The returned data aligns with `FinalActionResponse`, ensuring type consistency and making the structure
    clear for both developers and documentation readers. It can be consumed by a frontend component
    (e.g.,
    [Final.tsx](https://amsterdam-music-lab.github.io/MUSCLE/storybook/?path=/story/final--default)) to render the final screen.

    Args:
        session (Session): The current session object associated with the participant.
        title (str): The title displayed at the top of the final view. Defaults to a localized "Final score".
        final_text (Optional[str]): An optional concluding message (e.g., "Thanks for participating!").
        button (Optional[ButtonConfiguration]): Optional call-to-action button configuration. For example:
                                            {"text": "Play again", "link": "/{experiment_slug}"}.
        points (Optional[str]): The label for the score units (e.g., "points"). Defaults to a localized "points".
        rank (Optional[str]): The participant's rank (e.g., "GOLD"). If not provided, no rank is displayed.
        show_profile_link (bool): If True, display a link to the participant's profile.
        show_participant_link (bool): If True, display a participant-related link or information.
        show_participant_id_only (bool): If True, only the participant ID is shown, without a link.
        feedback_info (Optional[Dict[str, str]]): Optional dictionary containing feedback-related data. For example:
                                                  {"header": "Feedback", "prompt": "Tell us what you think", "button_text": "Submit"}.
        total_score (Optional[float]): Explicit final score. If None, this is derived from the session.
        logo (Optional[LogoConfiguration]): Optional logo configuration for branding. For example:
                                        {"image": "/static/logo.png", "link": "https://example.com"}.

    Note:
        The `action()` method returns a `FinalActionResponse` that can be consumed by the frontend
        to render the final screen.
    """

    view = "FINAL"

    RANKS = {
        "PLASTIC": {"text": _("plastic"), "class": "plastic"},
        "BRONZE": {"text": _("bronze"), "class": "bronze"},
        "SILVER": {"text": _("silver"), "class": "silver"},
        "GOLD": {"text": _("gold"), "class": "gold"},
        "PLATINUM": {"text": _("platinum"), "class": "platinum"},
        "DIAMOND": {"text": _("diamond"), "class": "diamond"},
    }

    def __init__(
        self,
        session: Session,
        title: str = _("Final score"),
        final_text: Optional[str] = None,
        button: Optional[ButtonConfiguration] = None,
        points: Optional[str] = None,
        rank: Optional[str] = None,
        show_profile_link: bool = False,
        show_participant_link: bool = False,
        show_participant_id_only: bool = False,
        feedback_info: FeedbackInfo | None = None,
        total_score: Optional[float] = None,
        logo: Optional[LogoConfiguration] = None,
        percentile: Optional[float] = None,
    ):
        self.session = session
        self.title = title
        self.final_text = final_text
        self.button = button
        self.rank = rank
        self.show_profile_link = show_profile_link
        self.show_participant_link = show_participant_link
        self.show_participant_id_only = show_participant_id_only
        self.feedback_info = feedback_info
        self.logo = logo
        self.percentile = percentile

        if total_score is None:
            self.total_score = self.session.total_score()
        else:
            self.total_score = total_score

        if points is None:
            self.points = _("points")
        else:
            self.points = points

    def action(self) -> FinalActionResponse:
        response: FinalActionResponse = {
            "view": self.view,
            "score": self.total_score,
            "percentile": self.percentile,
            "rank": self.rank,
            "final_text": self.wrap_plain_final_text(),
            "button": self.button,
            "points": self.points,
            "action_texts": {
                "play_again": _("Play again"),
                "profile": _("My profile"),
                "all_experiments": _("All experiments"),
            },
            "title": self.title,
            "social": self.get_social_media_config(self.session),
            "show_profile_link": self.show_profile_link,
            "show_participant_link": self.show_participant_link,
            "feedback_info": self.feedback_info,
            "participant_id_only": self.show_participant_id_only,
            "logo": self.logo,
        }

        return response

    def get_social_media_config(self, session: Session) -> Optional[SocialMediaConfigConfiguration]:
        experiment = session.block.phase.experiment
        if hasattr(experiment, "social_media_config") and experiment.social_media_config:
            return serialize_social_media_config(experiment.social_media_config, session.total_score())
        return None

    def wrap_plain_final_text(self):
        """check if `final_text` starts with a html tag
        If not, wrap it in a `<center>` element for better alignment
        """
        tag_pattern = re.compile("<[a-z]*>")
        if self.final_text and not re.match(tag_pattern, str(self.final_text)):
            return f"<center>{self.final_text}</center>"
        return self.final_text

wrap_plain_final_text() #

check if final_text starts with a html tag If not, wrap it in a <center> element for better alignment

Source code in experiment/actions/final.py
def wrap_plain_final_text(self):
    """check if `final_text` starts with a html tag
    If not, wrap it in a `<center>` element for better alignment
    """
    tag_pattern = re.compile("<[a-z]*>")
    if self.final_text and not re.match(tag_pattern, str(self.final_text)):
        return f"<center>{self.final_text}</center>"
    return self.final_text

LogoConfiguration #

Bases: TypedDict

Logo configuration for branding or visual identification on the final screen.

Attributes:

Name Type Description
image str

The URL of the logo image to display.

link str

The URL to navigate to when the logo is clicked.

Source code in experiment/actions/final.py
class LogoConfiguration(TypedDict):
    """
    Logo configuration for branding or visual identification on the final screen.

    Attributes:
        image (str): The URL of the logo image to display.
        link (str): The URL to navigate to when the logo is clicked.
    """

    image: str
    link: str

experiment.actions.form#

AutoCompleteQuestion #

Bases: Question

A question with an autocomplete input.

Parameters:

Name Type Description Default
choices Dict[str, str]

Available options

required
**kwargs

Additional Question arguments

{}
Example
question = AutoCompleteQuestion(
    key="color",
    question="What's your favorite color?",
    choices={
        "red": "Red", # or _("Red") for translation
        "green": "Green",
        "blue": "Blue",
    },
)
Source code in experiment/actions/form.py
class AutoCompleteQuestion(Question):
    """A question with an autocomplete input.

    Args:
        choices (Dict[str, str]): Available options
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = AutoCompleteQuestion(
            key="color",
            question="What's your favorite color?",
            choices={
                "red": "Red", # or _("Red") for translation
                "green": "Green",
                "blue": "Blue",
            },
        )
        ```
    """

    def __init__(self, choices: Dict[str, str], **kwargs) -> None:
        super().__init__(**kwargs)
        self.choices = choices
        self.view = "AUTOCOMPLETE"

BooleanQuestion #

Bases: Question

A yes/no question component.

Parameters:

Name Type Description Default
choices Optional[Dict[str, str]]

Custom yes/no labels

None
**kwargs

Additional Question arguments

{}
Example
question = BooleanQuestion(
    key="is_student",
    question="Are you a student?",
    choices={
        "no": "Nope", # Use _("No") for translation (default)
        "yes": "Yep"
    },
)
Source code in experiment/actions/form.py
class BooleanQuestion(Question):
    """A yes/no question component.

    Args:
        choices (Optional[Dict[str, str]]): Custom yes/no labels
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = BooleanQuestion(
            key="is_student",
            question="Are you a student?",
            choices={
                "no": "Nope", # Use _("No") for translation (default)
                "yes": "Yep"
            },
        )
        ```
    """

    def __init__(self, choices: Optional[Dict[str, str]] = None, **kwargs) -> None:
        super().__init__(**kwargs)
        self.choices = choices or {"no": _("No"), "yes": _("Yes")}
        self.view = "BUTTON_ARRAY"
        style = kwargs.get('style') or [
            ColorScheme.BOOLEAN_NEGATIVE_FIRST,
            ButtonStyle.LARGE_GAP,
        ]
        self.style = self._apply_style(style)

ButtonArrayQuestion #

Bases: Question

A question with a button array.

Parameters:

Name Type Description Default
choices Dict[str, str]

Available options

required
**kwargs

Additional Question arguments

{}
Example
question = ButtonArrayQuestion(
    key="color",
    question="What's your favorite color?",
    choices={
        "red": "Red", # or _("Red") for translation
        "green": "Green",
        "blue": "Blue",
    },
)
Source code in experiment/actions/form.py
class ButtonArrayQuestion(Question):
    """A question with a button array.

    Args:
        choices (Dict[str, str]): Available options
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = ButtonArrayQuestion(
            key="color",
            question="What's your favorite color?",
            choices={
                "red": "Red", # or _("Red") for translation
                "green": "Green",
                "blue": "Blue",
            },
        )
        ```
    """

    def __init__(self, choices: Dict[str, str], **kwargs) -> None:
        super().__init__(**kwargs)
        self.choices = choices
        self.view = "BUTTON_ARRAY"

ChoiceQuestion #

Bases: Question

A question with (multiple) choice options.

Parameters:

Name Type Description Default
choices Dict[str, str]

Available options

required
min_values int

Minimum number of selections required

1
**kwargs

Additional Question arguments

{}
Example
question = ChoiceQuestion(
    key="color",
    question="What's your favorite color?",
    choices={
        "red": "Red", # or _("Red") for translation
        "green": "Green",
        "blue": "Blue",
    },
    min_values=1,
)
Note
  • To have multiple choices (participant can select more than one answer), set view to ‘CHECKBOXES’
Source code in experiment/actions/form.py
class ChoiceQuestion(Question):
    """A question with (multiple) choice options.

    Args:
        choices (Dict[str, str]): Available options
        min_values (int): Minimum number of selections required
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = ChoiceQuestion(
            key="color",
            question="What's your favorite color?",
            choices={
                "red": "Red", # or _("Red") for translation
                "green": "Green",
                "blue": "Blue",
            },
            min_values=1,
        )
        ```

    Note:
        - To have multiple choices (participant can select more than one answer), set `view` to 'CHECKBOXES'
    """

    def __init__(self, choices: Dict[str, str], min_values: int = 1, **kwargs) -> None:
        super().__init__(**kwargs)
        self.choices = choices
        # minimal number of values to be selected, 1 or more
        # TODO: enforce (raise ValueError), or math.floor it
        self.min_values = min_values

DropdownQuestion #

Bases: Question

A question with a dropdown menu.

Parameters:

Name Type Description Default
choices Dict[str, str]

Available options

required
**kwargs

Additional Question arguments

{}
Example
question = DropdownQuestion(
    key="color",
    question="What's your favorite color?",
    choices={
        "red": "Red", # or _("Red") for translation
        "green": "Green",
        "blue": "Blue",
    },
)
Source code in experiment/actions/form.py
class DropdownQuestion(Question):
    """A question with a dropdown menu.

    Args:
        choices (Dict[str, str]): Available options
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = DropdownQuestion(
            key="color",
            question="What's your favorite color?",
            choices={
                "red": "Red", # or _("Red") for translation
                "green": "Green",
                "blue": "Blue",
            },
        )
        ```
    """

    def __init__(self, choices: Dict[str, str], **kwargs) -> None:
        super().__init__(**kwargs)
        self.choices = choices
        self.view = "DROPDOWN"

Form #

Bases: BaseAction

The Form action is a view which brings together an array of questions with a submit and an optional skip button.

Parameters:

Name Type Description Default
form List[Question]

List of question components

required
submit_label str

Text for submit button

gettext_lazy('Continue')
skip_label str

Text for skip button

gettext_lazy('Skip')
is_skippable bool

Whether form can be skipped

False
Example
form = Form([
    TextQuestion(
        key="name",
        question="What's your name?",
        explainer="Please enter your full name.",
    ),
    BooleanQuestion(
        key="is_student",
        question="Are you a student?",
    ),
])
Source code in experiment/actions/form.py
class Form(BaseAction):
    """The Form action is a view which brings together an array of questions with a submit and an optional skip button.

    Args:
        form (List[Question]): List of question components
        submit_label (str): Text for submit button
        skip_label (str): Text for skip button
        is_skippable (bool): Whether form can be skipped

    Example:
        ```python
        form = Form([
            TextQuestion(
                key="name",
                question="What's your name?",
                explainer="Please enter your full name.",
            ),
            BooleanQuestion(
                key="is_student",
                question="Are you a student?",
            ),
        ])
        ```
    """

    def __init__(
        self,
        form: List[Question],
        submit_label: str = _("Continue"),
        skip_label: str = _("Skip"),
        is_skippable: bool = False,
    ) -> None:
        self.form = form
        self.submit_label = submit_label
        self.skip_label = skip_label
        self.is_skippable = is_skippable

    def action(self) -> Dict[str, Any]:
        serialized_form = [question.action() for question in self.form]
        return {
            "form": serialized_form,
            "submit_label": self.submit_label,
            "skip_label": self.skip_label,
            "is_skippable": self.is_skippable,
        }

LikertQuestion #

Bases: Question

A question with a Likert scale.

Parameters:

Name Type Description Default
scale_steps int

Number of scale steps (default: 7)

7
explainer str

Additional instructions for the question

gettext_lazy('How much do you agree or disagree?')
likert_view LikertView

Likert scale view (default: ‘TEXT_RANGE’)

'TEXT_RANGE'
choices Dict[int, str]

Custom Likert scale labels

{}
**kwargs

Additional Question arguments

{}
Example
question = LikertQuestion(
    key="satisfaction",
    question="How satisfied are you with the service?",
    explainer="Please rate your satisfaction.",
    scale_steps=5,
    likert_view="TEXT_RANGE",
)
Source code in experiment/actions/form.py
class LikertQuestion(Question):
    """A question with a Likert scale.

    Args:
        scale_steps (int): Number of scale steps (default: 7)
        explainer (str): Additional instructions for the question
        likert_view (LikertView): Likert scale view (default: 'TEXT_RANGE')
        choices (Dict[int, str]): Custom Likert scale labels
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = LikertQuestion(
            key="satisfaction",
            question="How satisfied are you with the service?",
            explainer="Please rate your satisfaction.",
            scale_steps=5,
            likert_view="TEXT_RANGE",
        )
        ```
    """

    def __init__(
        self,
        scale_steps: int = 7,
        explainer: str = _("How much do you agree or disagree?"),
        likert_view: LikertView = "TEXT_RANGE",
        choices: Dict[int, str] = {},
        **kwargs,
    ) -> None:
        super().__init__(**kwargs)
        self.view = likert_view
        self.scoring_rule = "LIKERT"
        self.scale_steps = scale_steps
        self.explainer = explainer

        if choices:
            self.choices = choices
            self.scale_steps = len(self.choices)
        else:
            if scale_steps == 7:
                self.choices = {
                    1: _("Completely Disagree"),
                    2: _("Strongly Disagree"),
                    3: _("Disagree"),
                    4: _("Neither Agree nor Disagree"),  # Undecided
                    5: _("Agree"),
                    6: _("Strongly Agree"),
                    7: _("Completely Agree"),
                }
            elif scale_steps == 5:
                self.choices = {
                    1: _("Strongly Disagree"),
                    2: _("Disagree"),
                    3: _("Neither Agree nor Disagree"),  # Undecided
                    4: _("Agree"),
                    5: _("Strongly Agree"),
                }

LikertQuestionIcon #

Bases: Question

A question with a Likert scale using icons.

Parameters:

Name Type Description Default
scale_steps int

Number of scale steps (default: 7)

7
likert_view LikertView

Likert scale view (default: ‘ICON_RANGE’)

'ICON_RANGE'
**kwargs

Additional Question arguments

{}
Example
question = LikertQuestionIcon(
    key="satisfaction",
    question="How satisfied are you with the service?",
    scale_steps=5,
    likert_view="ICON_RANGE",
)
Source code in experiment/actions/form.py
class LikertQuestionIcon(Question):
    """A question with a Likert scale using icons.

    Args:
        scale_steps (int): Number of scale steps (default: 7)
        likert_view (LikertView): Likert scale view (default: 'ICON_RANGE')
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = LikertQuestionIcon(
            key="satisfaction",
            question="How satisfied are you with the service?",
            scale_steps=5,
            likert_view="ICON_RANGE",
        )
        ```
    """

    def __init__(self, scale_steps: int = 7, likert_view: LikertView = "ICON_RANGE", **kwargs) -> None:
        super().__init__(**kwargs)
        self.view = likert_view
        if scale_steps == 7:
            self.choices = {
                1: "fa-face-grin-hearts",
                2: "fa-face-grin",
                3: "fa-face-smile",
                4: "fa-face-meh",  # Undecided
                5: "fa-face-frown",
                6: "fa-face-frown-open",
                7: "fa-face-angry",
            }
            self.style = self._apply_style([ColorScheme.GRADIENT_7])

NumberQuestion #

Bases: Question

A question that accepts numeric input.

Parameters:

Name Type Description Default
input_type str

Type of number input (default: ‘number’)

'number'
min_value int

Minimum allowed value

0
max_value int

Maximum allowed value

120
**kwargs

Additional Question arguments

{}
Source code in experiment/actions/form.py
class NumberQuestion(Question):
    """A question that accepts numeric input.

    Args:
        input_type (str): Type of number input (default: 'number')
        min_value (int): Minimum allowed value
        max_value (int): Maximum allowed value
        **kwargs: Additional Question arguments
    """

    def __init__(self, input_type: str = "number", min_value: int = 0, max_value: int = 120, **kwargs) -> None:
        super().__init__(**kwargs)
        self.min_value = min_value
        self.max_value = max_value
        self.input_type = input_type
        self.view = "STRING"

Question #

Bases: BaseAction

The Question action is a component that represents a single question in a form.

Parameters:

Name Type Description Default
key str

Unique identifier for the question in results table

required
result_id Optional[int]

Optional result ID for testing purposes

None
view QuestionView

Widget type to use in frontend (default: ‘STRING’)

'STRING'
explainer str

Additional instructions for the question

''
question str

The actual question text

''
is_skippable bool

Whether question can be skipped

False
submits bool

Whether answering submits the form

False
style Optional[list[str]]

CSS class name(s) set in the frontend for styling

[value]
Example
question = Question(
    key="name",
    question="What's your name?",
    explainer="Please enter your full name.",
    is_skippable=True,
    submits=True,
    style=[ColorScheme.BOOLEAN],
    view="STRING",
)
Note
  • You can use any of the child classes to create a specific question type (e.g. TextQuestion, BooleanQuestion).

  • The key should be unique within the form.

Source code in experiment/actions/form.py
class Question(BaseAction):
    """The Question action is a component that represents a single question in a form.

    Args:
        key (str): Unique identifier for the question in results table
        result_id (Optional[int]): Optional result ID for testing purposes
        view (QuestionView): Widget type to use in frontend (default: 'STRING')
        explainer (str): Additional instructions for the question
        question (str): The actual question text
        is_skippable (bool): Whether question can be skipped
        submits (bool): Whether answering submits the form
        style Optional[list[str]]: CSS class name(s) set in the frontend for styling

    Example:
        ```python
        question = Question(
            key="name",
            question="What's your name?",
            explainer="Please enter your full name.",
            is_skippable=True,
            submits=True,
            style=[ColorScheme.BOOLEAN],
            view="STRING",
        )
        ```

    Note:
        - You can use any of the child classes to create a specific question type (e.g. TextQuestion, BooleanQuestion). \n
        - The `key` should be unique within the form. \n
    """

    def __init__(
        self,
        key: str,
        result_id: Optional[int] = None,
        view: QuestionView = "STRING",
        explainer: str = "",
        question: str = "",
        is_skippable: bool = False,
        submits: bool = False,
        style: list[str] = [ColorScheme.NEUTRAL.value],
    ) -> None:
        self.key = key
        self.view = view
        self.explainer = explainer
        self.question = question
        self.result_id = result_id
        self.is_skippable = is_skippable
        self.submits = submits
        self.style = self._apply_style(style)

    def action(self) -> Dict[str, Any]:
        if settings.TESTING and self.result_id:
            from result.models import Result

            result = Result.objects.get(pk=self.result_id)
            if result and result.expected_response:
                self.expected_response = result.expected_response
        return super().action()

RadiosQuestion #

Bases: Question

A question with radio buttons.

Parameters:

Name Type Description Default
choices Dict[str, str]

Available options

required
**kwargs

Additional Question arguments

{}
Example
question = RadiosQuestion(
    key="color",
    question="What's your favorite color?",
    choices={
        "red": "Red", # or _("Red") for translation
        "green": "Green",
        "blue": "Blue",
    },
)
Source code in experiment/actions/form.py
class RadiosQuestion(Question):
    """A question with radio buttons.

    Args:
        choices (Dict[str, str]): Available options
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = RadiosQuestion(
            key="color",
            question="What's your favorite color?",
            choices={
                "red": "Red", # or _("Red") for translation
                "green": "Green",
                "blue": "Blue",
            },
        )
        ```
    """

    def __init__(self, choices: Dict[str, str], **kwargs) -> None:
        super().__init__(**kwargs)
        self.choices = choices
        self.view = "RADIOS"

RangeQuestion #

Bases: Question

A question with a range slider.

Parameters:

Name Type Description Default
min_value int

Minimum value

required
max_value int

Maximum value

required
**kwargs

Additional Question arguments

{}
Example
question = RangeQuestion(
    key="age",
    question="How old are you?",
    min_value=18,
    max_value=120,
)
Source code in experiment/actions/form.py
class RangeQuestion(Question):
    """A question with a range slider.

    Args:
        min_value (int): Minimum value
        max_value (int): Maximum value
        **kwargs: Additional Question arguments

    Example:
        ```python
        question = RangeQuestion(
            key="age",
            question="How old are you?",
            min_value=18,
            max_value=120,
        )
        ```
    """

    def __init__(self, min_value: int, max_value: int, **kwargs) -> None:
        super().__init__(**kwargs)
        self.min_value = min_value
        self.max_value = max_value

TextQuestion #

Bases: Question

A question that accepts text input.

Parameters:

Name Type Description Default
input_type str

Type of text input (default: ‘text’)

'text'
max_length int

Maximum character length

64
**kwargs

Additional Question arguments

{}
Source code in experiment/actions/form.py
class TextQuestion(Question):
    """A question that accepts text input.

    Args:
        input_type (str): Type of text input (default: 'text')
        max_length (int): Maximum character length
        **kwargs: Additional Question arguments
    """

    def __init__(self, input_type: str = "text", max_length: int = 64, **kwargs) -> None:
        super().__init__(**kwargs)
        self.max_length = max_length  # the maximum length of the question's answer in characters
        self.input_type = input_type
        self.view = "STRING"

experiment.actions.html#

HTML #

Bases: BaseAction

An action that renders HTML content. See also the HTML.tsx component in the frontend project.

Parameters:

Name Type Description Default
body str

The HTML body content

required

Examples:

To render a simple HTML snippet with a title and a paragraph:

>>> html_action = HTML('<h1>Hello, world!</h1><p>This is a simple HTML snippet.</p>')
Source code in experiment/actions/html.py
class HTML(BaseAction):
    """An action that renders HTML content. See also the `HTML.tsx` component in the frontend project.

    Args:
        body (str): The HTML body content

    Examples:
        To render a simple HTML snippet with a title and a paragraph:

        >>> html_action = HTML('<h1>Hello, world!</h1><p>This is a simple HTML snippet.</p>')
    """

    view = "HTML"

    def __init__(self, body: str):
        self.body = body

experiment.actions.info#

Info #

Bases: BaseAction

Provide data for a view that presents information using HTML to the participant, along with a customizable (link) button.

Parameters:

Name Type Description Default
body str

HTML body

required
heading str

title/heading on top

''
button_label str

label of button on bottom

None
button_link str

(optional) button link. If no link is set, clicking the button will redirect the participant to the next action.

None
Example
Info(
    body="<p>Here is some information</p>",
    heading="This is the heading",
    button_label="Next",
    button_link="https://example.com",
)
Note

Relates to the Info.tsx component in the frontend.

Source code in experiment/actions/info.py
class Info(BaseAction):
    """
    Provide data for a view that presents information using HTML to the participant, along with a customizable (link) button.

    Args:
        body (str): HTML body
        heading (str): title/heading on top
        button_label (str): label of button on bottom
        button_link (str): (optional) button link. If no link is set, clicking the button will redirect the participant to the next action.

    Example:
        ```python
        Info(
            body="<p>Here is some information</p>",
            heading="This is the heading",
            button_label="Next",
            button_link="https://example.com",
        )
        ```

    Note:
        Relates to the `Info.tsx` component in the frontend.
    """

    view = "INFO"

    def __init__(self, body, heading="", button_label=None, button_link=None):
        self.body = body
        self.heading = heading
        self.button_label = button_label
        self.button_link = button_link

experiment.actions.playback#

Autoplay #

Bases: Playback

Player that starts playing automatically.

Parameters:

Name Type Description Default
sections List[Section]

List of audio sections to play.

required
**kwargs Any

Additional arguments passed to Playback.

{}
Example
Autoplay(
    [section1, section2],
    show_animation=True,
)
Note

If show_animation is True, displays a countdown and moving histogram.

Source code in experiment/actions/playback.py
class Autoplay(Playback):
    """Player that starts playing automatically.

    Args:
        sections List[Section]: List of audio sections to play.
        **kwargs: Additional arguments passed to `Playback`.

    Example:
        ```python
        Autoplay(
            [section1, section2],
            show_animation=True,
        )
        ```

    Note:
        If show_animation is True, displays a countdown and moving histogram.
    """

    def __init__(self, sections: List[Section], **kwargs: Any) -> None:
        super().__init__(sections, **kwargs)
        self.view = TYPE_AUTOPLAY

ImagePlayer #

Bases: Multiplayer

Multiplayer that shows an image next to each play button.

Parameters:

Name Type Description Default
sections List[Section]

List of audio sections to play.

required
images List[str]

List of image paths/urls to display.

required
image_labels List[str]

Optional labels for images. Defaults to empty list.

[]
**kwargs Any

Additional arguments passed to Multiplayer.

{}
Example
ImagePlayer(
    [section1, section2],
    images=["image1.jpg", "image2.jpg"],
    image_labels=["Image 1", "Image 2"],
)

Raises:

Type Description
UserWarning

If number of images or labels doesn’t match sections.

Source code in experiment/actions/playback.py
class ImagePlayer(Multiplayer):
    """Multiplayer that shows an image next to each play button.

    Args:
        sections (List[Section]): List of audio sections to play.
        images (List[str]): List of image paths/urls to display.
        image_labels (List[str]): Optional labels for images. Defaults to empty list.
        **kwargs: Additional arguments passed to Multiplayer.

    Example:
        ```python
        ImagePlayer(
            [section1, section2],
            images=["image1.jpg", "image2.jpg"],
            image_labels=["Image 1", "Image 2"],
        )
        ```

    Raises:
        UserWarning: If number of images or labels doesn't match sections.
    """

    def __init__(
        self,
        sections: List[Section],
        images: List[str],
        image_labels: List[str] = [],
        **kwargs: Any,
    ) -> None:
        super().__init__(sections, **kwargs)
        self.view = 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

MatchingPairs #

Bases: Multiplayer

Multiplayer where buttons are represented as cards and where the cards need to be matched based on audio.

Parameters:

Name Type Description Default
sections List[Section]

List of audio sections to play.

required
score_feedback_display ScoreFeedbackDisplay

How to display score feedback. Defaults to “large-top” (pick from “small-bottom-right”, “large-top”, “hidden”).

'large-top'
tutorial Optional[Dict[str, Any]]

Tutorial configuration dictionary. Defaults to None.

None
**kwargs Any

Additional arguments passed to Multiplayer.

{}
Example
MatchingPairs(
    # You will need an even number of sections (ex. 16)
    [section1, section2, section3, section4, section5, section6, section7, section8, section9, section10, section11, section12, section13, section14, section15, section16],
    score_feedback_display="large-top",
    tutorial={
        "no_match": _(
            "This was not a match, so you get 0 points. Please try again to see if you can find a matching pair."
        ),
        "lucky_match": _(
            "You got a matching pair, but you didn't hear both cards before. This is considered a lucky match. You get 10 points."
        ),
        "memory_match": _("You got a matching pair. You get 20 points."),
        "misremembered": _(
            "You thought you found a matching pair, but you didn't. This is considered a misremembered pair. You lose 10 points."
        ),
    }
)
Source code in experiment/actions/playback.py
class MatchingPairs(Multiplayer):
    """Multiplayer where buttons are represented as cards and where the cards need to be matched based on audio.

    Args:
        sections (List[Section]): List of audio sections to play.
        score_feedback_display (ScoreFeedbackDisplay): How to display score feedback. Defaults to "large-top" (pick from "small-bottom-right", "large-top", "hidden").
        tutorial (Optional[Dict[str, Any]]): Tutorial configuration dictionary. Defaults to None.
        **kwargs: Additional arguments passed to Multiplayer.

    Example:
        ```python
        MatchingPairs(
            # You will need an even number of sections (ex. 16)
            [section1, section2, section3, section4, section5, section6, section7, section8, section9, section10, section11, section12, section13, section14, section15, section16],
            score_feedback_display="large-top",
            tutorial={
                "no_match": _(
                    "This was not a match, so you get 0 points. Please try again to see if you can find a matching pair."
                ),
                "lucky_match": _(
                    "You got a matching pair, but you didn't hear both cards before. This is considered a lucky match. You get 10 points."
                ),
                "memory_match": _("You got a matching pair. You get 20 points."),
                "misremembered": _(
                    "You thought you found a matching pair, but you didn't. This is considered a misremembered pair. You lose 10 points."
                ),
            }
        )
        ```
    """

    def __init__(
        self,
        sections: List[Section],
        score_feedback_display: ScoreFeedbackDisplay = "large-top",
        tutorial: Optional[Dict[str, Any]] = None,
        **kwargs: Any,
    ) -> None:
        super().__init__(sections, **kwargs)
        self.view = TYPE_MATCHINGPAIRS
        self.score_feedback_display = score_feedback_display
        self.tutorial = tutorial

Multiplayer #

Bases: PlayButton

Player with multiple play buttons.

Parameters:

Name Type Description Default
sections List[Section]

List of audio sections to play.

required
stop_audio_after float

Seconds after which to stop audio. Defaults to 5.

5
labels List[str]

Custom labels for players. Defaults to empty list.

[]
style FrontendStyle

Frontend styling options.

None
**kwargs Any

Additional arguments passed to PlayButton.

{}
Example
Multiplayer(
    [section1, section2],
    stop_audio_after=3,
    labels=["Play 1", "Play 2"],
)

Raises:

Type Description
UserWarning

If labels is defined, and number of labels doesn’t match number of sections.

Source code in experiment/actions/playback.py
class Multiplayer(PlayButton):
    """Player with multiple play buttons.

    Args:
        sections (List[Section]): List of audio sections to play.
        stop_audio_after (float): Seconds after which to stop audio. Defaults to 5.
        labels (List[str]): Custom labels for players. Defaults to empty list.
        style (FrontendStyle): Frontend styling options.
        **kwargs: Additional arguments passed to PlayButton.

    Example:
        ```python
        Multiplayer(
            [section1, section2],
            stop_audio_after=3,
            labels=["Play 1", "Play 2"],
        )
        ```

    Raises:
        UserWarning: If `labels` is defined, and number of labels doesn't match number of sections.
    """

    def __init__(
        self,
        sections: List[Section],
        stop_audio_after: float = 5,
        labels: List[str] = [],
        style: Optional[list[str]] = None,
        **kwargs: Any,
    ) -> None:
        super().__init__(sections, **kwargs)
        self.view = TYPE_MULTIPLAYER
        self.stop_audio_after = stop_audio_after
        self.style = self._apply_style(style)
        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

PlayButton #

Bases: Playback

Player that shows a button to trigger playback.

Parameters:

Name Type Description Default
sections List[Section]

List of audio sections to play.

required
play_once bool

Whether button should be disabled after one play. Defaults to False.

False
**kwargs Any

Additional arguments passed to Playback.

{}
Example
PlayButton(
    [section1, section2],
    play_once=False,
)
Source code in experiment/actions/playback.py
class PlayButton(Playback):
    """Player that shows a button to trigger playback.

    Args:
        sections (List[Section]): List of audio sections to play.
        play_once: Whether button should be disabled after one play. Defaults to False.
        **kwargs: Additional arguments passed to Playback.

    Example:
        ```python
        PlayButton(
            [section1, section2],
            play_once=False,
        )
        ```
    """

    def __init__(self, sections: List[Section], play_once: bool = False, **kwargs: Any) -> None:
        super().__init__(sections, **kwargs)
        self.view = TYPE_BUTTON
        self.play_once = play_once

Playback #

Bases: BaseAction

Base class for different kinds of audio players.

Parameters:

Name Type Description Default
sections List[Section]

List of audio sections to play.

required
preload_message str

Text to display during preload. Defaults to “”.

''
instruction str

Text to display during presentation. Defaults to “”.

''
play_from float

Start position in seconds. Defaults to 0.

0
show_animation bool

Whether to show playback animation. Defaults to False.

False
mute bool

Whether to mute audio. Defaults to False.

False
timeout_after_playback Optional[float]

Seconds to wait after playback before proceeding. Defaults to None.

None
stop_audio_after Optional[float]

Seconds after which to stop playback. Defaults to None.

None
resume_play bool

Whether to resume from previous position. Defaults to False.

False
style Optional[list[str]]

CSS class name(s) set in the frontend for styling

None
tutorial Optional[Dict[str, Any]]

Tutorial configuration dictionary. Defaults to None.

None
Source code in experiment/actions/playback.py
class Playback(BaseAction):
    """Base class for different kinds of audio players.

    Args:
        sections (List[Section]): List of audio sections to play.
        preload_message (str): Text to display during preload. Defaults to "".
        instruction (str): Text to display during presentation. Defaults to "".
        play_from (float): Start position in seconds. Defaults to 0.
        show_animation (bool): Whether to show playback animation. Defaults to False.
        mute (bool): Whether to mute audio. Defaults to False.
        timeout_after_playback (Optional[float]): Seconds to wait after playback before proceeding. Defaults to None.
        stop_audio_after (Optional[float]): Seconds after which to stop playback. Defaults to None.
        resume_play (bool): Whether to resume from previous position. Defaults to False.
        style (Optional[list[str]]): CSS class name(s) set in the frontend for styling
        tutorial (Optional[Dict[str, Any]]): Tutorial configuration dictionary. Defaults to None.
    """

    sections: List[PlaybackSection]

    def __init__(
        self,
        sections: List[Section],
        preload_message: str = "",
        instruction: str = "",
        play_from: float = 0,
        show_animation: bool = False,
        mute: bool = False,
        timeout_after_playback: Optional[float] = None,
        stop_audio_after: Optional[float] = None,
        resume_play: bool = False,
        style: Optional[list[str]] = None,
        tutorial: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.sections = [{"id": s.id, "url": s.absolute_url(), "group": s.group} for s in sections]
        self.play_method = determine_play_method(sections[0])
        self.show_animation = show_animation
        self.preload_message = preload_message
        self.instruction = instruction
        self.play_from = play_from
        self.mute = mute
        self.timeout_after_playback = timeout_after_playback
        self.stop_audio_after = stop_audio_after
        self.resume_play = resume_play
        self.style = self._apply_style(style)
        self.tutorial = tutorial

determine_play_method(section) #

Determine which play method to use based on section properties.

Parameters:

Name Type Description Default
section Section

Audio section object.

required

Returns:

Name Type Description
str PlayMethods

Play method constant (PLAY_NOAUDIO, PLAY_EXTERNAL, PLAY_HTML, or PLAY_BUFFER).

Source code in experiment/actions/playback.py
def determine_play_method(section: Section) -> PlayMethods:
    """Determine which play method to use based on section properties.

    Args:
        section (Section): Audio section object.

    Returns:
        str: Play method constant (PLAY_NOAUDIO, PLAY_EXTERNAL, PLAY_HTML, or PLAY_BUFFER).
    """
    filename = str(section.filename)
    if not is_audio_file(filename):
        return PLAY_NOAUDIO
    elif filename.startswith("http"):
        return PLAY_EXTERNAL
    elif section.duration > 45:
        return PLAY_HTML
    else:
        return PLAY_BUFFER

is_audio_file(filename) #

Check if filename has an audio extension.

Parameters:

Name Type Description Default
filename str

Name of the file to check.

required

Returns:

Name Type Description
bool bool

True if file has an audio extension.

Source code in experiment/actions/playback.py
def is_audio_file(filename: str) -> bool:
    """Check if filename has an audio extension.

    Args:
        filename (str): Name of the file to check.

    Returns:
        bool: True if file has an audio extension.
    """
    return any(filename.lower().endswith(ext) for ext in audio_extensions)

experiment.actions.redirect#

Redirect #

Bases: BaseAction

Redirect Action

This action is used to redirect the user to a specified URL.

Parameters:

Name Type Description Default
url str

The URL to redirect to.

required
Example
redirect_action = Redirect('https://example.com')
Source code in experiment/actions/redirect.py
class Redirect(BaseAction):
    """
    Redirect Action

    This action is used to redirect the user to a specified URL.

    Args:
        url (str): The URL to redirect to.

    Example:
        ```python
        redirect_action = Redirect('https://example.com')
        ```
    """

    view = "REDIRECT"

    def __init__(self, url):
        self.url = url

experiment.actions.score#

Score #

Bases: BaseAction

Provide data for a score view, presenting feedback to a participant after a Trial.

Parameters:

Name Type Description Default
session Session

a Session object

required
title str

the title of the score page

''
result Optional[Result]

the result for which section and/or score should be reported

None
score Optional[float]

the score to report, will override result.score

None
score_message str

a function which constructs feedback text based on the score

''
config Optional[ScoreConfig]

a dict with the following settings:

None
icon Optional[str]

the name of a themify-icon shown with the view or None

None
timer Optional[int]

int or None. If int, wait for as many seconds until showing the next view

None
feedback Optional[str]

An additional feedback text

None
Note

Relates to the Score.tsx component in the frontend

Source code in experiment/actions/score.py
class Score(BaseAction):
    """
    Provide data for a score view, presenting feedback to a participant after a Trial.

    Args:
        session (Session): a Session object
        title (str): the title of the score page
        result (Optional[Result]): the result for which section and/or score should be reported
        score (Optional[float]): the score to report, will override result.score
        score_message: a function which constructs feedback text based on the score
        config (Optional[ScoreConfig]): a dict with the following settings:
        icon (Optional[str]): the name of a themify-icon shown with the view or None
        timer (Optional[int]): int or None. If int, wait for as many seconds until showing the next view
        feedback (Optional[str]): An additional feedback text

    Note:
        Relates to the Score.tsx component in the frontend
    """

    view = "SCORE"

    def __init__(
        self,
        session: Session,
        title: str = "",
        result: Optional[Result] = None,
        score: Optional[float] = None,
        score_message: str = "",
        config: Optional[ScoreConfig] = None,
        icon: Optional[str] = None,
        timer: Optional[int] = None,
        feedback: Optional[str] = None,
    ):
        self.title = title or _("Round {get_rounds_passed} / {total_rounds}").format(
            get_rounds_passed=session.get_rounds_passed(),
            total_rounds=session.block.rounds,
        )
        self.session = session
        self.score = self.get_score(score, result)
        self.score_message = score_message or self.default_score_message(self.score)
        self.feedback = feedback
        self.config = {"show_section": False, "show_total_score": False}
        if config:
            self.config.update(config)
        self.icon = icon
        self.texts = {"score": _("Total Score"), "next": _("Next"), "listen_explainer": _("You listened to:")}
        self.last_song = result.section.song_label() if result else session.last_song()
        self.timer = timer

    def action(self) -> dict:
        """Serialize score data

        Returns:
            dictionary with the relevant data for the Score.tsx view
        """
        # Create action
        action = {
            "view": self.view,
            "title": self.title,
            "score": self.score,
            "score_message": self.score_message,
            "texts": self.texts,
            "feedback": self.feedback,
            "icon": self.icon,
            "timer": self.timer,
        }
        if self.config["show_section"]:
            action["last_song"] = self.last_song
        if self.config["show_total_score"]:
            action["total_score"] = self.session.total_score()
        return action

    def get_score(self, score: Optional[float] = None, result: Optional[Result] = None) -> float:
        """Retrieve the last relevant score, fall back to session.last_score() if neither score nor result are defined

        Args:
            score: the score passed from the rules file (optional)
            result: a Result object passed from the rules file (opional)
        """
        if score:
            return score
        elif result:
            return result.score
        else:
            return self.session.last_score()

    def default_score_message(self, score):
        """Fallback to generate a message for the given score"""
        # None
        if score is None:
            score = 0

        # Zero
        if score == 0:
            # "Too bad!", "Come on!", "Try another!", "Try again!"
            return random.choice([_("No points")])

        # Negative
        if score < 0:
            return random.choice([_("Incorrect")])  # "Too bad!", "Fail!", "Nooo!"

        # Positive
        # "Well done!", "Nice job!", "Great one!", "Score!", "You're good!", "Awesome!", "Nice one!"
        return random.choice([_("Correct")])

action() #

Serialize score data

Returns:

Type Description
dict

dictionary with the relevant data for the Score.tsx view

Source code in experiment/actions/score.py
def action(self) -> dict:
    """Serialize score data

    Returns:
        dictionary with the relevant data for the Score.tsx view
    """
    # Create action
    action = {
        "view": self.view,
        "title": self.title,
        "score": self.score,
        "score_message": self.score_message,
        "texts": self.texts,
        "feedback": self.feedback,
        "icon": self.icon,
        "timer": self.timer,
    }
    if self.config["show_section"]:
        action["last_song"] = self.last_song
    if self.config["show_total_score"]:
        action["total_score"] = self.session.total_score()
    return action

default_score_message(score) #

Fallback to generate a message for the given score

Source code in experiment/actions/score.py
def default_score_message(self, score):
    """Fallback to generate a message for the given score"""
    # None
    if score is None:
        score = 0

    # Zero
    if score == 0:
        # "Too bad!", "Come on!", "Try another!", "Try again!"
        return random.choice([_("No points")])

    # Negative
    if score < 0:
        return random.choice([_("Incorrect")])  # "Too bad!", "Fail!", "Nooo!"

    # Positive
    # "Well done!", "Nice job!", "Great one!", "Score!", "You're good!", "Awesome!", "Nice one!"
    return random.choice([_("Correct")])

get_score(score=None, result=None) #

Retrieve the last relevant score, fall back to session.last_score() if neither score nor result are defined

Parameters:

Name Type Description Default
score Optional[float]

the score passed from the rules file (optional)

None
result Optional[Result]

a Result object passed from the rules file (opional)

None
Source code in experiment/actions/score.py
def get_score(self, score: Optional[float] = None, result: Optional[Result] = None) -> float:
    """Retrieve the last relevant score, fall back to session.last_score() if neither score nor result are defined

    Args:
        score: the score passed from the rules file (optional)
        result: a Result object passed from the rules file (opional)
    """
    if score:
        return score
    elif result:
        return result.score
    else:
        return self.session.last_score()

ScoreConfig #

Bases: TypedDict

Configuration for the Score action

Parameters:

Name Type Description Default
show_section

whether metadata of the previous section should be shown, often the title of the last played song

required
show_total_score

whether the total score should be shown in the view

required
Source code in experiment/actions/score.py
class ScoreConfig(TypedDict):
    """
    Configuration for the Score action

    Args:
        show_section: whether metadata of the previous section should be shown, often the title of the last played song
        show_total_score: whether the total score should be shown in the view
    """

    show_section: bool
    show_total_score: bool

experiment.actions.styles#

ColorScheme #

Bases: StrEnum

Enumeration of valid color schemes that can be applied to arrays of (playback) buttons

Note

Color schemes are mutually exclusive. Possible values are:

  • BOOLEAN

  • BOOLEAN_NEGATIVE_FIRST

  • NEUTRAL

  • NEUTRAL_INVERTED

Source code in experiment/actions/styles.py
class ColorScheme(StrEnum):
    """
    Enumeration of valid color schemes that can be applied to arrays of (playback) buttons

    Note:
        Color schemes are mutually exclusive. Possible values are: \n
        - BOOLEAN \n
        - BOOLEAN_NEGATIVE_FIRST \n
        - NEUTRAL \n
        - NEUTRAL_INVERTED \n
    """

    BOOLEAN = "boolean"
    BOOLEAN_NEGATIVE_FIRST = "boolean-negative-first"
    NEUTRAL = "neutral"
    NEUTRAL_INVERTED = "neutral-inverted"
    GRADIENT_7 = 'gradient-7'
    TOONTJEHOGER = 'toontjehoger'  # style with 5 different colors for buttons and links

FrontendStyle #

A class to manage and apply frontend styles to different elements.

The FrontendStyle class allows setting and managing styles for various UI elements, with validation against predefined style options. To be used in conjunction with Playback and Question actions.

Parameters:

Name Type Description Default
styles

list[str]

list[str]
Example
style = FrontendStyle([ColorScheme.NEUTRAL, ButtonStyle.LARGE_GAP, TextStyle.INVISIBLE])
Source code in experiment/actions/styles.py
class FrontendStyle:
    """
    A class to manage and apply frontend styles to different elements.

    The FrontendStyle class allows setting and managing styles for various UI elements,
    with validation against predefined style options. To be used in conjunction with Playback and Question actions.

    Args:
        styles: list[str]

    Example:
        ```python
        style = FrontendStyle([ColorScheme.NEUTRAL, ButtonStyle.LARGE_GAP, TextStyle.INVISIBLE])
        ```
    """

    def __init__(self, styles=list[str]) -> None:
        for style in styles:
            self._assert_valid(style)
        self._assert_no_conflicts(styles)
        self.styles = styles

    def _assert_valid(self, style: str) -> bool:
        if style in ButtonStyle or style in ColorScheme or style in TextStyle:
            return True
        else:
            valid_styles = ", ".join(
                self._get_permissible_styles(ButtonStyle)
                + self._get_permissible_styles(ColorScheme)
                + self._get_permissible_styles(TextStyle)
            )
            raise ValueError(
                f"Invalid style: {style}. Valid styles are {valid_styles}."
            )

    def _assert_no_conflicts(self, styles: list[str]) -> bool:
        """assert that the styles array does not contain two conflicting color schemes"""
        color_schemes = list(set(styles) & set(ColorScheme))
        if len(color_schemes) <= 1:
            return True
        else:
            raise ConflictingStylesException(
                f"Cannot combine two color schemes: {color_schemes}"
            )

    def to_dict(self) -> Dict[str, str]:
        return {style: True for style in self.styles}

    def _get_permissible_styles(self, style_enum: StrEnum) -> list[str]:
        return [str(member) for member in style_enum]

experiment.actions.trial#

Config #

Bases: TypedDict

Configuration for the Trial action.

Parameters:

Name Type Description Default
- response_time (int

how long to wait until stopping the player

required
- auto_advance (bool

proceed automatically after stopping

required
- listen_first (bool

block form submission until playback ends

required
- show_continue_button (bool

display a ‘Continue’ button

required
- continue_label (str

label for the continue button

required
Source code in experiment/actions/trial.py
class Config(TypedDict):
    """
    Configuration for the Trial action.

    Args:
      - response_time (int): how long to wait until stopping the player
      - auto_advance (bool): proceed automatically after stopping
      - listen_first (bool): block form submission until playback ends
      - show_continue_button (bool): display a 'Continue' button
      - continue_label (str): label for the continue button
    """

    response_time: int
    auto_advance: bool
    listen_first: bool
    show_continue_button: bool
    continue_label: str

Trial #

Bases: BaseAction

A view that may include Playback and/or a Form to be displayed to the participant.

Parameters:

Name Type Description Default
playback Optional[Playback]

Player(s) to be displayed in this view (e.g. audio, video, image)

None
html Optional[str]

HTML to be displayed in this view

None
feedback_form Optional[Form]

array of form elements

None
title Optional(str

page title - defaults to empty

''
config Optional[Config]

configuration for the trial with options for response time, auto advance, listen first, show continue button, and continue label

None
Example
key = 'test_trial'
section = session.playlist.get_section()
question = BooleanQuestion(
    question=_(
        "Do you like this song?"),
    key=key,
    result_id=prepare_result(key, session, section=section),
    submits=True
)
form = Form([question])
playback = Autoplay([section])
view = Trial(
    playback=playback,
    feedback_form=form,
    title=_('Test block'),
    config={
        'response_time': section.duration,
        'listen_first': True
    }
)
Note

Relates to client component: Trial.tsx

Source code in experiment/actions/trial.py
class Trial(BaseAction):  # pylint: disable=too-few-public-methods
    """
    A view that may include Playback and/or a Form to be displayed to the participant.

    Args:
        playback (Optional[Playback]): Player(s) to be displayed in this view (e.g. audio, video, image)
        html (Optional[str]): HTML to be displayed in this view
        feedback_form (Optional[Form]): array of form elements
        title (Optional(str)): page title - defaults to empty
        config (Optional[Config]): configuration for the trial with options for response time, auto advance, listen first, show continue button, and continue label

    Example:
        ```python
        key = 'test_trial'
        section = session.playlist.get_section()
        question = BooleanQuestion(
            question=_(
                "Do you like this song?"),
            key=key,
            result_id=prepare_result(key, session, section=section),
            submits=True
        )
        form = Form([question])
        playback = Autoplay([section])
        view = Trial(
            playback=playback,
            feedback_form=form,
            title=_('Test block'),
            config={
                'response_time': section.duration,
                'listen_first': True
            }
        )
        ```

    Note:
        Relates to client component: Trial.tsx
    """

    view = "TRIAL_VIEW"

    def __init__(
        self,
        playback: Optional[Playback] = None,
        html: Optional[str] = None,
        feedback_form: Optional[Form] = None,
        title="",
        config: Optional[Config] = None,
    ):
        self.playback = playback
        self.html = html
        self.feedback_form = feedback_form
        self.title = title
        self.config = {
            "response_time": 5,
            "auto_advance": False,
            "listen_first": False,
            "show_continue_button": True,
            "continue_label": _("Continue"),
        }
        if config:
            self.config.update(config)

    def action(self):
        """
        Serialize data for a block action

        """
        # Create action
        action = {
            "view": self.view,
            "title": self.title,
            "config": self.config,
        }
        if self.playback:
            action["playback"] = self.playback.action()
        if self.html:
            action["html"] = self.html.action()
        if self.feedback_form:
            action["feedback_form"] = self.feedback_form.action()

        return action

action() #

Serialize data for a block action

Source code in experiment/actions/trial.py
def action(self):
    """
    Serialize data for a block action

    """
    # Create action
    action = {
        "view": self.view,
        "title": self.title,
        "config": self.config,
    }
    if self.playback:
        action["playback"] = self.playback.action()
    if self.html:
        action["html"] = self.html.action()
    if self.feedback_form:
        action["feedback_form"] = self.feedback_form.action()

    return action

experiment.actions.utils#

final_action_with_optional_button(session, final_text='', title=_('End'), button_text=_('Continue')) #

Description: Create a final action with an optional button to proceed to the next block, if available.

Parameters:

Name Type Description Default
session Session

The current session.

required
final_text str

The text to display in the final action.

''
title str

The title for the final action screen.

gettext_lazy('End')
button_text str

The text displayed on the continuation button.

gettext_lazy('Continue')

Returns:

Type Description
Final

The final action with an optional button.

Example
action = final_action_with_optional_button(my_session, final_text="Complete!")
Source code in experiment/actions/utils.py
def final_action_with_optional_button(session, final_text="", title=_("End"), button_text=_("Continue")) -> Final:
    """
    Description: Create a final action with an optional button to proceed to the next block, if available.

    Args:
        session (Session): The current session.
        final_text (str): The text to display in the final action.
        title (str): The title for the final action screen.
        button_text (str): The text displayed on the continuation button.

    Returns:
        (Final): The final action with an optional button.

    Example:
        ```python
        action = final_action_with_optional_button(my_session, final_text="Complete!")
        ```
    """
    redirect_url = get_current_experiment_url(session)

    if redirect_url:
        return Final(
            title=title, session=session, final_text=final_text, button={"text": button_text, "link": redirect_url}
        )
    else:
        return Final(
            title=title,
            session=session,
            final_text=final_text,
        )

get_average_difference(session, num_turnpoints, initial_value) #

Description: Calculate and return the average difference in milliseconds participants could hear (from the last num_turnpoints records).

Parameters:

Name Type Description Default
session Session

The current session.

required
num_turnpoints int

The number of last turnpoints to consider.

required
initial_value float

A fallback initial value.

required

Returns:

Type Description
float

The average difference in milliseconds.

Example
avg_diff = get_average_difference(my_session, 3, 20.0)
Source code in experiment/actions/utils.py
def get_average_difference(session, num_turnpoints, initial_value) -> float:
    """
    Description: Calculate and return the average difference in milliseconds participants could hear (from the last `num_turnpoints` records).

    Args:
        session (Session): The current session.
        num_turnpoints (int): The number of last turnpoints to consider.
        initial_value (float): A fallback initial value.

    Returns:
        (float): The average difference in milliseconds.

    Example:
        ```python
        avg_diff = get_average_difference(my_session, 3, 20.0)
        ```
    """
    last_turnpoints = get_last_n_turnpoints(session, num_turnpoints)
    if last_turnpoints.count() == 0:
        last_result = get_fallback_result(session)
        if last_result:
            return float(last_result.section.song.name)
        else:
            # this cannot happen in DurationDiscrimination style blocks
            # for future compatibility, still catch the condition that there may be no results
            return initial_value
    return sum([int(result.section.song.name) for result in last_turnpoints]) / last_turnpoints.count()

get_average_difference_level_based(session, num_turnpoints, initial_value) #

Description: Calculate the difference level based on exponential decay.

Parameters:

Name Type Description Default
session Session

The current session.

required
num_turnpoints int

The number of last turnpoints to consider.

required
initial_value float

The starting reference value.

required

Returns:

Type Description
float

The average difference in milliseconds.

Example
level_diff = get_average_difference_level_based(my_session, 5, 20.0)
Source code in experiment/actions/utils.py
def get_average_difference_level_based(session, num_turnpoints, initial_value) -> float:
    """
    Description: Calculate the difference level based on exponential decay.

    Args:
        session (Session): The current session.
        num_turnpoints (int): The number of last turnpoints to consider.
        initial_value (float): The starting reference value.

    Returns:
        (float): The average difference in milliseconds.

    Example:
        ```python
        level_diff = get_average_difference_level_based(my_session, 5, 20.0)
        ```
    """
    last_turnpoints = get_last_n_turnpoints(session, num_turnpoints)
    if last_turnpoints.count() == 0:
        # outliers
        last_result = get_fallback_result(session)
        if last_result:
            return initial_value / (2 ** (int(last_result.section.song.name.split("_")[-1]) - 1))
        else:
            # participant didn't pay attention,
            # no results right after the practice rounds
            return initial_value
    # Difference by level starts at initial value (which is level 1, so 20/(2^0)) and then halves for every next level
    return (
        sum([initial_value / (2 ** (int(result.section.song.name.split("_")[-1]) - 1)) for result in last_turnpoints])
        / last_turnpoints.count()
    )

get_current_experiment_url(session) #

Description: Retrieve the URL for the current experiment.

Parameters:

Name Type Description Default
session Session

The current user experiment session.

required

Returns:

Type Description
str | None

The URL for the current experiment.

Example
url = get_current_experiment_url(session)
Note

Returns None if there is no experiment slug.

Source code in experiment/actions/utils.py
def get_current_experiment_url(session: Session) -> str | None:
    """
    Description: Retrieve the URL for the current experiment.

    Args:
        session (Session): The current user experiment session.

    Returns:
        (str | None): The URL for the current experiment.

    Example:
        ```python
        url = get_current_experiment_url(session)
        ```

    Note:
        Returns None if there is no experiment slug.
    """
    experiment_slug = session.json_data.get(EXPERIMENT_KEY)
    if not experiment_slug:
        return None

    if session.participant.participant_id_url:
        participant_id_url = session.participant.participant_id_url
        return f"/{experiment_slug}?participant_id={participant_id_url}"
    else:
        return f"/{experiment_slug}"

get_fallback_result(session) #

Description: Retrieve a fallback result if no turnpoints are found.

Parameters:

Name Type Description Default
session Session

The current session.

required

Returns:

Type Description
Result | None

The fallback result.

Example
fallback = get_fallback_result(my_session)
Source code in experiment/actions/utils.py
def get_fallback_result(session) -> Result | None:
    """
    Description: Retrieve a fallback result if no turnpoints are found.

    Args:
        session (Session): The current session.

    Returns:
        (Result | None): The fallback result.

    Example:
        ```python
        fallback = get_fallback_result(my_session)
        ```
    """
    if session.result_set.count() == 0:
        # stopping right after practice rounds
        return None

    # TODO: Check if this is the correct way to get the last result as Python says .order_by returns a "Unknown" type
    result = session.result_set.order_by("-created_at")[0]
    return result

get_last_n_turnpoints(session, num_turnpoints) #

Description: Return the specified number of most recent turnpoint results from the session.

Parameters:

Name Type Description Default
session Session

The current session.

required
num_turnpoints int

How many latest turnpoint results to retrieve.

required

Returns:

Type Description
QuerySet[Result]

The latest turnpoint results.

Example
turnpoints = get_last_n_turnpoints(my_session, 3)
Source code in experiment/actions/utils.py
def get_last_n_turnpoints(session, num_turnpoints) -> QuerySet[Result]:
    """
    Description: Return the specified number of most recent turnpoint results from the session.

    Args:
        session (Session): The current session.
        num_turnpoints (int): How many latest turnpoint results to retrieve.

    Returns:
        (QuerySet[Result]): The latest turnpoint results.

    Example:
        ```python
        turnpoints = get_last_n_turnpoints(my_session, 3)
        ```
    """
    all_results = session.result_set.filter(comment__iendswith="turnpoint").order_by("-created_at").all()
    cutoff = min(all_results.count(), num_turnpoints)
    return all_results[:cutoff]

randomize_playhead(min_jitter, max_jitter, continuation_correctness) #

Description: Randomly add to the playhead offset. If continuation_correctness=True, this function returns 0, and has no effect on the playhead.

Parameters:

Name Type Description Default
min_jitter float

Minimum offset.

required
max_jitter float

Maximum offset.

required
continuation_correctness bool

whether to add a random increment to the continued audio

required

Returns:

Type Description
float

The random offset.

Example
offset = randomize_playhead(0.5, 1.5, False)
Source code in experiment/actions/utils.py
def randomize_playhead(min_jitter, max_jitter, continuation_correctness) -> float:
    """
    Description: Randomly add to the playhead offset. If `continuation_correctness=True`, this function returns 0, and has no effect on the playhead.

    Args:
        min_jitter (float): Minimum offset.
        max_jitter (float): Maximum offset.
        continuation_correctness (bool): whether to add a random increment to the continued audio

    Returns:
        (float): The random offset.

    Example:
        ```python
        offset = randomize_playhead(0.5, 1.5, False)
        ```
    """
    return random.uniform(min_jitter, max_jitter) if not continuation_correctness else 0

render_feedback_trivia(feedback, trivia) #

Description: Render feedback and trivia into the final template.

Parameters:

Name Type Description Default
feedback str

The feedback text.

required
trivia str

The trivia text.

required

Returns:

Type Description
str

The rendered HTML.

Example
rendered = render_feedback_trivia("Good job!", "Did you know ...?")

Note: Can be used as the final_text parameter in the Final action or the final_action_with_optional_button function.

Source code in experiment/actions/utils.py
def render_feedback_trivia(feedback, trivia) -> str:
    """
    Description: Render feedback and trivia into the final template.

    Args:
        feedback (str): The feedback text.
        trivia (str): The trivia text.

    Returns:
        (str): The rendered HTML.

    Example:
        ```python
        rendered = render_feedback_trivia("Good job!", "Did you know ...?")
        ```

    Note: Can be used as the `final_text` parameter in the `Final` action or the `final_action_with_optional_button` function.
    """
    context = {"feedback": feedback, "trivia": trivia}
    return render_to_string(join("final", "feedback_trivia.html"), context)

experiment.actions.wrappers#

song_sync(session, section, title, recognition_time=15, sync_time=15, min_jitter=10, max_jitter=15) #

Description: Provide a series of Trials for song recognition and sync, including optional jitter.

Parameters:

Name Type Description Default
session Session

Current user session.

required
section Section

Section to use for playback and silence intervals.

required
title str

Title to be displayed for the trials.

required
recognition_time int

Response time for recognition.

15
sync_time int

Response time for syncing continuation.

15
min_jitter int

Minimum playback offset for continuation correctness trial.

10
max_jitter int

Maximum playback offset for continuity trial.

15

Returns:

Type Description
list

A list of Trials (recognize, silence, correct_place).

Example
trials = song_sync(session, section, _("Song Sync"), recognition_time=10)
Source code in experiment/actions/wrappers.py
def song_sync(
    session: Session, section: Section, title: str, recognition_time=15, sync_time=15, min_jitter=10, max_jitter=15
):
    """
    Description: Provide a series of Trials for song recognition and sync, including optional jitter.

    Args:
        session (Session): Current user session.
        section (Section): Section to use for playback and silence intervals.
        title (str): Title to be displayed for the trials.
        recognition_time (int, optional): Response time for recognition.
        sync_time (int, optional): Response time for syncing continuation.
        min_jitter (int, optional): Minimum playback offset for continuation correctness trial.
        max_jitter (int, optional): Maximum playback offset for continuity trial.

    Returns:
        (list): A list of Trials (recognize, silence, correct_place).

    Example:
        ```python
        trials = song_sync(session, section, _("Song Sync"), recognition_time=10)
        ```
    """
    trial_config = {"response_time": recognition_time, "auto_advance": True}
    recognize = Trial(
        feedback_form=Form(
            [
                BooleanQuestion(
                    key="recognize",
                    result_id=prepare_result(
                        "recognize", session, section=section, scoring_rule="SONG_SYNC_RECOGNITION"
                    ),
                    submits=True,
                )
            ]
        ),
        playback=Autoplay(
            [section],
            show_animation=True,
            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=Autoplay([section], show_animation=True, instruction=_("Keep imagining the music"), mute=True),
        config={
            "response_time": silence_time,
            "auto_advance": True,
            "show_continue_button": False,
        },
        title=title,
    )
    continuation_correctness = random.choice([True, False])
    jitter = randomize_playhead(min_jitter, max_jitter, continuation_correctness)
    trial_config["response_time"] = sync_time
    correct_place = Trial(
        feedback_form=Form(
            [
                BooleanQuestion(
                    key="correct_place",
                    submits=True,
                    result_id=prepare_result(
                        "correct_place",
                        session,
                        section=section,
                        scoring_rule="SONG_SYNC_VERIFICATION",
                        json_data={"continuation_offset": jitter},
                        expected_response="yes" if continuation_correctness else "no",
                    ),
                )
            ]
        ),
        playback=Autoplay(
            [section],
            instruction=_("Did the track come back in the right place?"),
            show_animation=True,
            play_from=silence_time + jitter,
            resume_play=True,
        ),
        config=trial_config,
        title=title,
    )
    return [recognize, silence, correct_place]

two_alternative_forced(session, section, choices, expected_response=None, style=None, comment='', scoring_rule=None, title='', config=None) #

Description: Provide data for a two-alternative forced choice (2AFC) view, with an optional comment and scoring.

Parameters:

Name Type Description Default
session Session

Current user session.

required
section Section

Section to use in the playback or question.

required
choices dict

Possible choices presented to the user.

required
expected_response str

The expected user response.

None
style dict

Additional style configurations for buttons.

None
comment str

Comment or additional info about the session.

''
scoring_rule str

Rule for scoring the user’s response.

None
title str

Title to be displayed in the trial.

''
config dict

Additional configuration parameters.

None

Returns:

Type Description
Trial

Configured trial containing a playback, a question, and a feedback form.

Example
trial = two_alternative_forced(session, section, {"yes": "Yes", "no": "No"}, expected_response="yes", title=_("Two Alternative Forced Choice"))
Source code in experiment/actions/wrappers.py
def two_alternative_forced(
    session: Session,
    section: Section,
    choices: dict,
    expected_response: str = None,
    style: list[str] = None,
    comment: str = "",
    scoring_rule: str = None,
    title: str = "",
    config: Optional[dict] = None,
):
    """
    Description: Provide data for a two-alternative forced choice (2AFC) view, with an optional comment and scoring.

    Args:
        session (Session): Current user session.
        section (Section): Section to use in the playback or question.
        choices (dict): Possible choices presented to the user.
        expected_response (str, optional): The expected user response.
        style (dict, optional): Additional style configurations for buttons.
        comment (str, optional): Comment or additional info about the session.
        scoring_rule (str, optional): Rule for scoring the user's response.
        title (str, optional): Title to be displayed in the trial.
        config (dict, optional): Additional configuration parameters.

    Returns:
        (Trial): Configured trial containing a playback, a question, and a feedback form.

    Example:
        ```python
        trial = two_alternative_forced(session, section, {"yes": "Yes", "no": "No"}, expected_response="yes", title=_("Two Alternative Forced Choice"))
        ```
    """
    playback = PlayButton([section], {"listen_once": True})
    key = "choice"
    button_style = [TextStyle.INVISIBLE, ButtonStyle.LARGE_GAP, ButtonStyle.LARGE_TEXT]
    button_style.extend(style)
    question = ChoiceQuestion(
        key=key,
        result_id=prepare_result(
            key,
            session=session,
            section=section,
            expected_response=expected_response,
            scoring_rule=scoring_rule,
            comment=comment,
        ),
        choices=choices,
        view="BUTTON_ARRAY",
        submits=True,
        style=button_style,
    )
    feedback_form = Form([question])
    trial = Trial(playback=playback, feedback_form=feedback_form, title=title, config=config)
    return trial