Skip to content

Commit

Permalink
Docs: Refactor Playback classes and enhance type annotations (#1448)
Browse files Browse the repository at this point in the history
* refactor: Update scoreFeedbackDisplay type to use ScoreFeedbackDisplay enum

* docs: Enhance type annotations and documentation for Playback classes and methods

* Update backend/experiment/actions/playback.py

Co-authored-by: Berit <berit.janssen@gmail.com>

* refactor: Add TODO to remove 'group' from PlaybackSection and Playback class

---------

Co-authored-by: Berit <berit.janssen@gmail.com>
  • Loading branch information
drikusroor and BeritJanssen authored Jan 7, 2025
1 parent 787e700 commit d956640
Show file tree
Hide file tree
Showing 3 changed files with 186 additions and 57 deletions.
234 changes: 180 additions & 54 deletions backend/experiment/actions/playback.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from typing import List, Dict
from typing import List, Dict, Optional, Any, Literal, TypedDict

from .frontend_style import FrontendStyle
from .base_action import BaseAction
from section.validators import audio_extensions

from section.models import Section

# player types
TYPE_AUTOPLAY = "AUTOPLAY"
TYPE_BUTTON = "BUTTON"
Expand All @@ -17,34 +19,50 @@
PLAY_BUFFER = "BUFFER"
PLAY_NOAUDIO = "NOAUDIO"

PlayMethods = Literal["EXTERNAL", "HTML", "BUFFER", "NOAUDIO"]


class PlaybackSection(TypedDict):
id: int
url: str

# TODO: Remove group from PlaybackSection and from the Playback class itself and make sure everything still works (see also https://github.com/Amsterdam-Music-Lab/MUSCLE/pull/1448#discussion_r1903978068)
group: str


class Playback(BaseAction):
"""A playback base class for different kinds of players
- sections: a list of sections (in many cases, will only contain *one* section)
- preload_message: text to display during preload
- instruction: text to display during presentation of the sound
- play_from: where in the audio file to start playing/
- show_animation: whether to show animations with this player
- mute: whether to mute the audio
- timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding
- stop_audio_after: stop playback after so many seconds
- resume_play: if the playback should resume from where a previous view left off
"""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 (FrontendStyle): Frontend styling options. Defaults to FrontendStyle().
tutorial (Optional[Dict[str, Any]]): Tutorial configuration dictionary. Defaults to None.
"""

sections: List[PlaybackSection]

def __init__(
self,
sections,
preload_message="",
instruction="",
play_from=0,
show_animation=False,
mute=False,
timeout_after_playback=None,
stop_audio_after=None,
resume_play=False,
style=FrontendStyle(),
tutorial: Dict | None = None,
):
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: FrontendStyle = FrontendStyle(),
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
Expand All @@ -60,43 +78,83 @@ def __init__(


class Autoplay(Playback):
"""
This player starts playing automatically
- show_animation: if True, show a countdown and moving histogram
"""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, **kwargs):
def __init__(self, sections: List[Section], **kwargs: Any) -> None:
super().__init__(sections, **kwargs)
self.ID = TYPE_AUTOPLAY


class PlayButton(Playback):
"""
This player shows a button, which triggers playback
- play_once: if True, button will be disabled after one play
"""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, play_once=False, **kwargs):
def __init__(self, sections: List[Section], play_once: bool = False, **kwargs: Any) -> None:
super().__init__(sections, **kwargs)
self.ID = TYPE_BUTTON
self.play_once = play_once


class Multiplayer(PlayButton):
"""
This is a player with multiple play buttons
- stop_audio_after: after how many seconds to stop audio
- labels: pass list of strings if players should have custom labels
"""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. Defaults to FrontendStyle().
**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,
stop_audio_after=5,
labels=[],
style=FrontendStyle(),
**kwargs,
):
sections: List[Section],
stop_audio_after: float = 5,
labels: List[str] = [],
style: FrontendStyle = FrontendStyle(),
**kwargs: Any,
) -> None:
super().__init__(sections, **kwargs)
self.ID = TYPE_MULTIPLAYER
self.stop_audio_after = stop_audio_after
Expand All @@ -108,12 +166,34 @@ def __init__(


class ImagePlayer(Multiplayer):
"""
This is a special case of the Multiplayer:
it shows an image next to each play button
"""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, images, image_labels=[], **kwargs):
def __init__(
self,
sections: List[Section],
images: List[str],
image_labels: List[str] = [],
**kwargs: Any,
) -> None:
super().__init__(sections, **kwargs)
self.ID = TYPE_IMAGE
if len(images) != len(self.sections):
Expand All @@ -125,24 +205,62 @@ def __init__(self, sections, images, image_labels=[], **kwargs):
self.image_labels = image_labels


ScoreFeedbackDisplay = Literal["small-bottom-right", "large-top", "hidden"]


class MatchingPairs(Multiplayer):
"""
This is a special case of multiplayer:
play buttons are represented as cards
- sections: a list of sections (in many cases, will only contain *one* section)
- score_feedback_display: how to display the score feedback (large-top, small-bottom-right, hidden)
"""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[Dict], score_feedback_display: str = "large-top", tutorial: Dict | None = None, **kwargs
):
self,
sections: List[Section],
score_feedback_display: ScoreFeedbackDisplay = "large-top",
tutorial: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> None:
super().__init__(sections, **kwargs)
self.ID = TYPE_MATCHINGPAIRS
self.score_feedback_display = score_feedback_display
self.tutorial = tutorial


def determine_play_method(section):
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
Expand All @@ -154,5 +272,13 @@ def determine_play_method(section):
return PLAY_BUFFER


def is_audio_file(filename):
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)
5 changes: 3 additions & 2 deletions frontend/src/components/MatchingPairs/MatchingPairs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { Card } from "@/types/Section";
import Session from "@/types/Session";
import Participant from "@/types/Participant";
import Overlay from "../Overlay/Overlay";
import { ScoreFeedbackDisplay } from "@/types/Playback";

export const SCORE_FEEDBACK_DISPLAY = {
export const SCORE_FEEDBACK_DISPLAY: { [key: string]: ScoreFeedbackDisplay } = {
SMALL_BOTTOM_RIGHT: 'small-bottom-right',
LARGE_TOP: 'large-top',
HIDDEN: 'hidden',
Expand All @@ -22,7 +23,7 @@ interface MatchingPairsProps {
playerIndex: number;
showAnimation: boolean;
finishedPlaying: () => void;
scoreFeedbackDisplay?: string;
scoreFeedbackDisplay?: ScoreFeedbackDisplay;
submitResult: (result: any) => void;
tutorial?: { [key: string]: string };
view: string;
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/types/Playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface FrontendStyle {
[key: string]: string | FrontendStyle;
}

export type ScoreFeedbackDisplay = "large-top" | "small-bottom-right" | "hidden";

export interface PlaybackArgs {
view: PlaybackView;
play_method: PlaybackMethod;
Expand All @@ -34,6 +36,6 @@ export interface PlaybackArgs {
resume_play?: boolean;
stop_audio_after?: number;
timeout_after_playback?: number;
score_feedback_display?: string;
score_feedback_display?: ScoreFeedbackDisplay;
tutorial?: { [key: string]: string };
}

0 comments on commit d956640

Please sign in to comment.