Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them, up to six, in folder order (depth-first, alphabetical). Each is its own album, titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays the album it played last, or the first, straight away; another figure placed in between forgets the position in that album, not the album itself. Meanwhile the ring offers all of them: one section per album from a fixed palette, edges shifting towards the neighbours' hue, the selected one bright and wobbling. The knob moves the selection one section per detent and wraps; pressing plays the selected album, and left alone for 8 s the picker fades into the figure's colours and the music carries on. While open, the device hands the knob to the picker (`capture_knob`): turns do not change the volume and the press is not published to Home Assistant. A tick per step and a confirm "pling" go with it. The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23), re-sent on every detent with the section to glide from. The look is tunable through a temporary `general.figure_selection` config section until the right values become constants. Every UI sound - the screen's blubb, the parents' timer, the picker - now follows the music volume with a floor, times a new parent-mode "Effekt-Lautstärke" (`general.effects_volume`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from musicmouse.effects import (
|
||||
EffectCircularConfig,
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectSegmentSelect,
|
||||
EffectStaticConfig,
|
||||
EffectStaticDetailedConfig,
|
||||
EffectSwipeAndChange,
|
||||
@@ -135,7 +136,54 @@ def test_swipe_and_change_is_the_two_payloads_concatenated() -> None:
|
||||
(EffectRandomTwoColorInterpolationConfig(), 35),
|
||||
(EffectReverseSwipe(), 12),
|
||||
(EffectSwipeAndChange(), 64),
|
||||
(EffectSegmentSelect(colors=(ColorRGBW(0, 0, 0, 0),)), 53),
|
||||
],
|
||||
)
|
||||
def test_payload_sizes(effect: LedEffect, size: int) -> None:
|
||||
assert len(effect.as_bytes()) == size
|
||||
|
||||
|
||||
def test_segment_select() -> None:
|
||||
effect = EffectSegmentSelect(
|
||||
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 0, 1, 0)),
|
||||
selected=1,
|
||||
previous=0,
|
||||
gap_leds=2,
|
||||
offset_degrees=180.0,
|
||||
direction=-1,
|
||||
dim_factor=0.5,
|
||||
wobble_amplitude=1.5,
|
||||
wobble_period_ms=1000.0,
|
||||
slide_ms=200.0,
|
||||
edge_blend=0.25,
|
||||
)
|
||||
assert effect.as_bytes().hex() == (
|
||||
"02" "01" "00" "02" # count, selected, previous, gap
|
||||
"ff000000" "0000ff00" + "00000000" * 4 # always six colours on the wire
|
||||
+ "00003443" # offset 180.0
|
||||
"ff" # direction -1
|
||||
"0000003f" # dim 0.5
|
||||
"0000c03f" # wobble 1.5
|
||||
"00007a44" # period 1000.0
|
||||
"00004843" # slide 200.0
|
||||
"0000803e" # edge blend 0.25
|
||||
)
|
||||
|
||||
|
||||
def test_segment_select_without_previous_does_not_glide() -> None:
|
||||
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * 3, selected=2)
|
||||
assert effect.as_bytes()[:3] == bytes([3, 2, 2])
|
||||
|
||||
|
||||
def test_segment_select_round_trips() -> None:
|
||||
# Every float exact in float32, as the firmware has them.
|
||||
effect = EffectSegmentSelect(
|
||||
colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)), previous=1, dim_factor=0.25
|
||||
)
|
||||
assert EffectSegmentSelect.from_bytes(effect.as_bytes()) == effect
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [0, 7])
|
||||
def test_segment_select_takes_one_to_six_sections(count: int) -> None:
|
||||
with pytest.raises(ValueError, match="between 1 and 6"):
|
||||
EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0),) * count)
|
||||
|
||||
@@ -15,7 +15,14 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
|
||||
from musicmouse.effects import SEGMENT_SELECT_MAX_SEGMENTS
|
||||
from musicmouse.library import (
|
||||
MAX_FIGURE_ALBUMS,
|
||||
Album,
|
||||
Analyzer,
|
||||
MusicLibrary,
|
||||
default_worker_count,
|
||||
)
|
||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||
from musicmouse.library.cache import LibraryCache
|
||||
from musicmouse.library.colors import colors_from_id
|
||||
@@ -92,15 +99,72 @@ async def test_figure_playlists_are_keyed_by_figure_name(config_dir: Path) -> No
|
||||
playlists = library.figure_playlists()
|
||||
|
||||
assert set(playlists) == {"fuchs", "eule"}
|
||||
assert len(playlists["fuchs"]) == 3
|
||||
assert [track.path.name for track in playlists["fuchs"].tracks] == [
|
||||
(fuchs,) = playlists["fuchs"]
|
||||
assert len(fuchs) == 3
|
||||
assert [track.path.name for track in fuchs.tracks] == [
|
||||
"00 - track.mp3",
|
||||
"01 - track.mp3",
|
||||
"02 - track.mp3",
|
||||
]
|
||||
# The playlist carries the album it came from, which is how a front-end answers
|
||||
# "what is playing?" without keeping its own copy of that state.
|
||||
assert playlists["fuchs"].album_id == album_named(library, "Fuchs").id
|
||||
assert fuchs.album_id == album_named(library, "Fuchs").id
|
||||
|
||||
|
||||
def _figure_with_albums(config_dir: Path, figure: str, *folders: str) -> None:
|
||||
"""Replace a figure's audio with one album per folder (relative to the figure)."""
|
||||
figure_folder = config_dir / "music" / "Figuren" / figure
|
||||
for track in figure_folder.glob("*.mp3"):
|
||||
track.unlink()
|
||||
for folder in folders:
|
||||
write_track(figure_folder / folder / "01 - track.mp3", title=f"In {folder}")
|
||||
|
||||
|
||||
async def test_a_figure_with_album_subfolders_offers_each_in_folder_order(
|
||||
config_dir: Path,
|
||||
) -> None:
|
||||
# Depth-first and alphabetical, however deep an album sits.
|
||||
_figure_with_albums(
|
||||
config_dir, "fuchs", "B Lieder", "A Geschichten/Teil 2", "A Geschichten/Teil 1"
|
||||
)
|
||||
library = await build(config_dir)
|
||||
|
||||
choices = library.figure_playlists()["fuchs"]
|
||||
|
||||
albums = [library.get(playlist.album_id) for playlist in choices]
|
||||
assert [album.title if album else None for album in albums] == [
|
||||
"Fuchs - Teil 1",
|
||||
"Fuchs - Teil 2",
|
||||
"Fuchs - B Lieder",
|
||||
]
|
||||
assert all(album is not None and album.figure == "fuchs" for album in albums)
|
||||
|
||||
|
||||
async def test_a_figure_album_subfolder_takes_the_figures_kind(config_dir: Path) -> None:
|
||||
_figure_with_albums(config_dir, "eule", "Folge 1", "Folge 2")
|
||||
library = await build(config_dir)
|
||||
|
||||
albums = [library.get(p.album_id) for p in library.figure_playlists()["eule"]]
|
||||
assert len(albums) == 2
|
||||
assert {album.kind if album else None for album in albums} == {"book"}
|
||||
|
||||
|
||||
async def test_a_figure_offers_no_more_albums_than_the_ring_has_room_for(
|
||||
config_dir: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
_figure_with_albums(config_dir, "fuchs", *(f"Album {n}" for n in range(MAX_FIGURE_ALBUMS + 2)))
|
||||
library = await build(config_dir)
|
||||
|
||||
choices = library.figure_playlists()["fuchs"]
|
||||
|
||||
assert len(choices) == MAX_FIGURE_ALBUMS
|
||||
assert "only the first 6" in caplog.text
|
||||
# Browsing still shows every one of them.
|
||||
assert sum(album.figure == "fuchs" for album in library.albums) == MAX_FIGURE_ALBUMS + 2
|
||||
|
||||
|
||||
def test_the_album_cap_is_the_rings_section_count() -> None:
|
||||
assert MAX_FIGURE_ALBUMS == SEGMENT_SELECT_MAX_SEGMENTS
|
||||
|
||||
|
||||
async def test_music_takes_its_title_and_artist_from_the_tags(config_dir: Path) -> None:
|
||||
|
||||
@@ -14,6 +14,7 @@ from musicmouse.effects import (
|
||||
OFF,
|
||||
EffectAlexaSwipeConfig,
|
||||
EffectCircularConfig,
|
||||
EffectSegmentSelect,
|
||||
EffectStaticConfig,
|
||||
)
|
||||
from musicmouse.events import (
|
||||
@@ -23,9 +24,12 @@ from musicmouse.events import (
|
||||
Event,
|
||||
LedEffectChanged,
|
||||
RfidTokenRead,
|
||||
RotaryTurned,
|
||||
SelectorPressed,
|
||||
SelectorTurned,
|
||||
TouchButtonPressed,
|
||||
)
|
||||
from musicmouse.hardware import Button, ButtonAction, LedZone, TouchButton
|
||||
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
|
||||
from musicmouse.simulator import FakeTransport
|
||||
|
||||
FUCHS = bytes.fromhex("04a1b2c3d4")
|
||||
@@ -82,6 +86,56 @@ async def test_button_press_reaches_the_bus(
|
||||
]
|
||||
|
||||
|
||||
def _turn(increment: int, direction: RotaryDirection) -> bytes:
|
||||
return encode_input_event(RotaryTurned(position=0, increment=increment, direction=direction))
|
||||
|
||||
|
||||
def _knob(action: ButtonAction) -> bytes:
|
||||
return encode_input_event(ButtonEvent(button=Button.ROTARY, action=action))
|
||||
|
||||
|
||||
async def test_a_captured_knob_turns_the_selector_not_the_volume(
|
||||
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||
) -> None:
|
||||
device.capture_knob(True)
|
||||
device.feed(_turn(2, RotaryDirection.UP))
|
||||
device.feed(_turn(1, RotaryDirection.DOWN))
|
||||
# The first turn after boot reports no increment, but it was still a detent.
|
||||
device.feed(_turn(0, RotaryDirection.UP))
|
||||
await bus.drain()
|
||||
|
||||
assert [e.steps for e in only(seen, SelectorTurned)] == [2, -1, 1]
|
||||
assert not only(seen, RotaryTurned)
|
||||
|
||||
|
||||
async def test_a_captured_knob_press_is_the_selectors_alone(
|
||||
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||
) -> None:
|
||||
device.capture_knob(True)
|
||||
for action in (ButtonAction.PRESSED, ButtonAction.RELEASED):
|
||||
device.feed(_knob(action))
|
||||
# The other buttons are not captured.
|
||||
device.feed(encode_input_event(ButtonEvent(button=Button.LEFT, action=ButtonAction.PRESSED)))
|
||||
await bus.drain()
|
||||
|
||||
assert len(only(seen, SelectorPressed)) == 1
|
||||
assert [e.button for e in only(seen, ButtonEvent)] == [Button.LEFT]
|
||||
|
||||
|
||||
async def test_a_released_knob_is_a_volume_knob_again(
|
||||
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||
) -> None:
|
||||
device.capture_knob(True)
|
||||
device.capture_knob(False)
|
||||
device.feed(_turn(1, RotaryDirection.UP))
|
||||
device.feed(_knob(ButtonAction.PRESSED))
|
||||
await bus.drain()
|
||||
|
||||
assert len(only(seen, RotaryTurned)) == 1
|
||||
assert len(only(seen, ButtonEvent)) == 1
|
||||
assert not only(seen, SelectorTurned) and not only(seen, SelectorPressed)
|
||||
|
||||
|
||||
async def test_touch_press_reaches_the_bus(
|
||||
bus: EventBus, device: MusicMouseDevice, seen: list[Event]
|
||||
) -> None:
|
||||
@@ -255,6 +309,19 @@ async def test_an_effect_a_zone_does_not_support_is_reported_not_sent(
|
||||
assert "cannot be sent to the mouse LEDs" in caplog.text
|
||||
|
||||
|
||||
async def test_only_the_ring_can_be_a_selector(
|
||||
bus: EventBus, device: MusicMouseDevice, transport: FakeTransport
|
||||
) -> None:
|
||||
effect = EffectSegmentSelect(colors=(ColorRGBW(1, 0, 0, 0), ColorRGBW(0, 1, 0, 0)))
|
||||
device.set_effect(LedZone.RING, effect)
|
||||
device.set_effect(LedZone.SHELF, effect)
|
||||
await bus.drain()
|
||||
|
||||
sent = transport.effect(LedZone.RING)
|
||||
assert sent is not None and sent.as_bytes() == effect.as_bytes()
|
||||
assert transport.effect(LedZone.SHELF) is None
|
||||
|
||||
|
||||
async def test_button_brightness_sets_both_backlights(
|
||||
device: MusicMouseDevice, transport: FakeTransport
|
||||
) -> None:
|
||||
|
||||
371
python-backend/tests/test_selection.py
Normal file
371
python-backend/tests/test_selection.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Picking one of a figure's albums on the LED ring, driven through the simulator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.blubb import confirm_wav, tick_wav
|
||||
from musicmouse.config import load_config
|
||||
from musicmouse.effects import (
|
||||
EffectRandomTwoColorInterpolationConfig,
|
||||
EffectReverseSwipe,
|
||||
EffectSegmentSelect,
|
||||
EffectSwipeAndChange,
|
||||
)
|
||||
from musicmouse.events import ButtonEvent, Event, FigureSelectionChanged, RotaryTurned
|
||||
from musicmouse.hardware import LedZone
|
||||
from musicmouse.reactions import selection
|
||||
from musicmouse.reactions.selection import CONFIRM_GAIN, DEFAULTS, PALETTE, TICK_GAIN
|
||||
from musicmouse.simulator.driver import SimulatorDriver
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from musicmouse.sound import ui_level
|
||||
from tests.conftest import VALID_CONFIG, write_config, write_track
|
||||
|
||||
TRACK_SECONDS = 10.0
|
||||
TIMEOUT = DEFAULTS.timeout
|
||||
|
||||
T = TypeVar("T", bound=Event)
|
||||
|
||||
|
||||
def _albums(config_dir: Path) -> None:
|
||||
"""Give the fox three albums of two tracks each; the owl keeps its one."""
|
||||
fuchs = config_dir / "music" / "Figuren" / "fuchs"
|
||||
for track in fuchs.glob("*.mp3"):
|
||||
track.unlink()
|
||||
for folder in ("A", "B", "C"):
|
||||
for index in range(2):
|
||||
write_track(fuchs / folder / f"{index:02d}.mp3", title=f"{folder}{index}")
|
||||
|
||||
|
||||
async def _simulation(config_dir: Path, config: dict[str, Any]) -> Simulation:
|
||||
_albums(config_dir)
|
||||
return await build_simulation(
|
||||
load_config(write_config(config_dir, config)), track_duration=TRACK_SECONDS
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sim(config_dir: Path) -> AsyncIterator[Simulation]:
|
||||
simulation = await _simulation(config_dir, VALID_CONFIG)
|
||||
try:
|
||||
yield simulation
|
||||
finally:
|
||||
await simulation.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mouse(sim: Simulation) -> SimulatorDriver:
|
||||
return sim.driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seen(sim: Simulation) -> list[Event]:
|
||||
events: list[Event] = []
|
||||
sim.bus.subscribe_all(events.append)
|
||||
return events
|
||||
|
||||
|
||||
def only(events: list[Event], event_type: type[T]) -> list[T]:
|
||||
return [e for e in events if isinstance(e, event_type)]
|
||||
|
||||
|
||||
def ring(sim: Simulation) -> object:
|
||||
return sim.app.mouse.effect(LedZone.RING)
|
||||
|
||||
|
||||
def picker(sim: Simulation) -> EffectSegmentSelect:
|
||||
effect = ring(sim)
|
||||
assert isinstance(effect, EffectSegmentSelect)
|
||||
return effect
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- opening
|
||||
|
||||
|
||||
async def test_a_figure_with_several_albums_plays_the_first_and_offers_all(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("playing", "true")
|
||||
mouse.check("album", "Fuchs - A")
|
||||
effect = picker(sim)
|
||||
assert effect.colors == PALETTE[:3]
|
||||
assert effect.selected == 0
|
||||
# Only the ring is the picker; the body and shelf run the usual start animation.
|
||||
assert isinstance(sim.app.mouse.effect(LedZone.MOUSE), EffectSwipeAndChange)
|
||||
assert isinstance(sim.app.mouse.effect(LedZone.SHELF), EffectSwipeAndChange)
|
||||
|
||||
|
||||
async def test_a_figure_with_one_album_gets_no_picker(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("eule")
|
||||
|
||||
assert isinstance(ring(sim), EffectSwipeAndChange)
|
||||
assert sim.app.state.selector is None
|
||||
assert not sim.app.mouse.knob_captured
|
||||
|
||||
|
||||
async def test_the_config_can_override_how_the_picker_looks(config_dir: Path) -> None:
|
||||
config = deepcopy(VALID_CONFIG)
|
||||
config["general"]["figure_selection"] = {"offset_degrees": 90, "wobble_amplitude": 3}
|
||||
sim = await _simulation(config_dir, config)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
effect = picker(sim)
|
||||
assert effect.offset_degrees == 90
|
||||
assert effect.wobble_amplitude == 3
|
||||
assert effect.slide_ms == DEFAULTS.slide_ms
|
||||
finally:
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- knob
|
||||
|
||||
|
||||
async def test_turning_moves_the_selection_instead_of_the_volume(
|
||||
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
volume = sim.player.volume
|
||||
|
||||
await mouse.turn(1)
|
||||
|
||||
effect = picker(sim)
|
||||
assert (effect.previous, effect.selected) == (0, 1)
|
||||
assert sim.player.volume == volume
|
||||
assert not only(seen, RotaryTurned)
|
||||
assert only(seen, FigureSelectionChanged)[-1].selected == 1
|
||||
# Turning only moves the highlight; what plays changes on a press.
|
||||
mouse.check("album", "Fuchs - A")
|
||||
|
||||
|
||||
async def test_turning_wraps_round_the_ring(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||
await mouse.place("fuchs")
|
||||
|
||||
await mouse.turn(-1)
|
||||
assert picker(sim).selected == 2
|
||||
await mouse.turn(2)
|
||||
assert picker(sim).selected == 1
|
||||
|
||||
|
||||
async def test_pressing_plays_the_selected_album_and_gives_the_knob_back(
|
||||
mouse: SimulatorDriver, sim: Simulation, seen: list[Event]
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(2)
|
||||
|
||||
await mouse.press("rotary")
|
||||
|
||||
mouse.check("album", "Fuchs - C")
|
||||
mouse.check("track", "0")
|
||||
mouse.check("playing", "true")
|
||||
effect = ring(sim)
|
||||
assert isinstance(effect, EffectRandomTwoColorInterpolationConfig)
|
||||
assert effect.start_with_existing # a fade from the sections, not a jump
|
||||
# The press was the picker's: Home Assistant never hears of it.
|
||||
assert not only(seen, ButtonEvent)
|
||||
assert only(seen, FigureSelectionChanged)[-1].open is False
|
||||
|
||||
volume = sim.player.volume
|
||||
await mouse.turn(-1)
|
||||
assert sim.player.volume < volume
|
||||
|
||||
|
||||
async def test_pressing_on_the_album_already_playing_does_not_restart_it(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.wait(3)
|
||||
|
||||
await mouse.press("rotary")
|
||||
|
||||
mouse.check("album", "Fuchs - A")
|
||||
assert sim.player.position == pytest.approx(3.0)
|
||||
assert sim.app.state.selector is None
|
||||
|
||||
|
||||
async def test_presses_reach_home_assistant_again_once_the_picker_is_closed(
|
||||
mouse: SimulatorDriver, seen: list[Event]
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.press("rotary")
|
||||
await mouse.press("rotary")
|
||||
|
||||
assert len(only(seen, ButtonEvent)) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ timeout
|
||||
|
||||
|
||||
async def test_left_alone_the_picker_closes_and_the_music_carries_on(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
|
||||
await mouse.wait(TIMEOUT + 0.1)
|
||||
|
||||
assert sim.app.state.selector is None
|
||||
assert not sim.app.mouse.knob_captured
|
||||
assert isinstance(ring(sim), EffectRandomTwoColorInterpolationConfig)
|
||||
mouse.check("album", "Fuchs - A")
|
||||
mouse.check("playing", "true")
|
||||
|
||||
|
||||
async def test_every_turn_starts_the_timeout_over(mouse: SimulatorDriver, sim: Simulation) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.wait(TIMEOUT - 2)
|
||||
await mouse.turn(1)
|
||||
|
||||
await mouse.wait(TIMEOUT - 1)
|
||||
assert sim.app.state.selector is not None
|
||||
|
||||
await mouse.wait(1.1)
|
||||
assert sim.app.state.selector is None
|
||||
|
||||
|
||||
async def test_lifting_the_figure_closes_the_picker(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.remove()
|
||||
|
||||
assert sim.app.state.selector is None
|
||||
assert not sim.app.mouse.knob_captured
|
||||
assert isinstance(ring(sim), EffectReverseSwipe)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- memory
|
||||
|
||||
|
||||
async def test_putting_the_figure_back_resumes_the_picked_album(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
await mouse.press("rotary")
|
||||
await mouse.wait(3)
|
||||
await mouse.remove()
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - B")
|
||||
assert sim.player.position == pytest.approx(3.0)
|
||||
assert picker(sim).selected == 1
|
||||
|
||||
|
||||
async def test_another_figure_in_between_keeps_the_album_but_not_the_place(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
await mouse.press("rotary")
|
||||
await mouse.wait(TRACK_SECONDS + 3)
|
||||
await mouse.remove()
|
||||
await mouse.place("eule")
|
||||
await mouse.remove()
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - B")
|
||||
mouse.check("track", "0")
|
||||
assert sim.player.position == pytest.approx(0.0)
|
||||
assert picker(sim).selected == 1
|
||||
|
||||
|
||||
async def test_an_album_played_to_the_end_is_still_the_one_picked_next_time(
|
||||
mouse: SimulatorDriver,
|
||||
) -> None:
|
||||
await mouse.place("fuchs")
|
||||
await mouse.turn(1)
|
||||
await mouse.press("rotary")
|
||||
await mouse.wait(2 * TRACK_SECONDS + 1)
|
||||
mouse.check("playing", "false")
|
||||
await mouse.remove()
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - B")
|
||||
mouse.check("track", "0")
|
||||
|
||||
|
||||
async def test_an_album_started_from_the_web_becomes_the_figures_pick(
|
||||
mouse: SimulatorDriver, sim: Simulation
|
||||
) -> None:
|
||||
await mouse.play_album("Fuchs - C")
|
||||
await mouse.wait(2)
|
||||
|
||||
await mouse.place("fuchs")
|
||||
|
||||
mouse.check("album", "Fuchs - C")
|
||||
assert picker(sim).selected == 2
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- sound
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sounds(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, bytes]]:
|
||||
played: list[tuple[str, bytes]] = []
|
||||
monkeypatch.setattr(
|
||||
selection, "play_wav_soon", lambda device, wav: played.append((device, wav))
|
||||
)
|
||||
return played
|
||||
|
||||
|
||||
async def _audible(config_dir: Path, effects_volume: int = 100) -> Simulation:
|
||||
config = deepcopy(VALID_CONFIG)
|
||||
config["general"]["alsa_device"] = "softvol_effects"
|
||||
config["general"]["effects_volume"] = effects_volume
|
||||
return await _simulation(config_dir, config)
|
||||
|
||||
|
||||
async def test_each_step_ticks_but_fast_turns_do_not_pile_up(
|
||||
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||
) -> None:
|
||||
sim = await _audible(config_dir)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
await sim.driver.turn(1)
|
||||
await sim.driver.turn(1) # no time passed: one tick for both
|
||||
await sim.driver.wait(0.1)
|
||||
await sim.driver.turn(1)
|
||||
|
||||
level = ui_level(sim.player.volume / 100, 1.0)
|
||||
assert sounds == [("softvol_effects", tick_wav(gain=TICK_GAIN * level))] * 2
|
||||
finally:
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
async def test_picking_an_album_plays_the_confirm_sound(
|
||||
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||
) -> None:
|
||||
sim = await _audible(config_dir, effects_volume=50)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
await sim.driver.press("rotary")
|
||||
|
||||
level = ui_level(sim.player.volume / 100, 0.5)
|
||||
assert sounds == [("softvol_effects", confirm_wav(gain=CONFIRM_GAIN * level))]
|
||||
finally:
|
||||
await sim.aclose()
|
||||
|
||||
|
||||
async def test_no_picker_sounds_with_effects_turned_off(
|
||||
config_dir: Path, sounds: list[tuple[str, bytes]]
|
||||
) -> None:
|
||||
sim = await _audible(config_dir, effects_volume=0)
|
||||
try:
|
||||
await sim.driver.place("fuchs")
|
||||
await sim.driver.turn(1)
|
||||
await sim.driver.press("rotary")
|
||||
assert sounds == []
|
||||
finally:
|
||||
await sim.aclose()
|
||||
@@ -20,6 +20,7 @@ SETTINGS = {
|
||||
"initial_volume": 40,
|
||||
"volume_increment": 5,
|
||||
"button_leds_brightness": 0.5,
|
||||
"effects_volume": 100,
|
||||
}
|
||||
|
||||
#: A config file with comments, to prove saving does not flatten them.
|
||||
|
||||
47
python-backend/tests/test_sound.py
Normal file
47
python-backend/tests/test_sound.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""How loud UI sounds are, and the album picker's two sounds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.blubb import RATE, confirm_wav, tick_wav
|
||||
from musicmouse.sound import MIN_PLAYER_VOLUME, ui_level
|
||||
|
||||
|
||||
def test_ui_sounds_follow_the_music_volume() -> None:
|
||||
assert ui_level(0.8, 1.0) == pytest.approx(0.8)
|
||||
assert ui_level(1.0, 0.5) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_ui_sounds_never_drop_below_the_minimum_player_volume() -> None:
|
||||
# A muted player must not mute the timer, nor make the album picker silent.
|
||||
assert ui_level(0.0, 1.0) == pytest.approx(MIN_PLAYER_VOLUME)
|
||||
assert ui_level(0.0, 0.5) == pytest.approx(MIN_PLAYER_VOLUME * 0.5)
|
||||
|
||||
|
||||
def test_parents_can_still_turn_ui_sounds_off() -> None:
|
||||
assert ui_level(0.8, 0.0) == 0.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("make", "longest"), [(tick_wav, 0.05), (confirm_wav, 0.4)])
|
||||
def test_the_picker_sounds_are_short_mono_wavs(make: Callable[..., bytes], longest: float) -> None:
|
||||
with wave.open(io.BytesIO(make(gain=0.5))) as wav:
|
||||
assert (wav.getnchannels(), wav.getsampwidth(), wav.getframerate()) == (1, 2, RATE)
|
||||
assert 0 < wav.getnframes() / RATE < longest
|
||||
|
||||
|
||||
def test_the_gain_sets_the_peak() -> None:
|
||||
def peak(data: bytes) -> int:
|
||||
with wave.open(io.BytesIO(data)) as wav:
|
||||
frames = wav.readframes(wav.getnframes())
|
||||
return max(
|
||||
abs(int.from_bytes(frames[i : i + 2], "little", signed=True))
|
||||
for i in range(0, len(frames), 2)
|
||||
)
|
||||
|
||||
assert peak(tick_wav(gain=1.0)) == pytest.approx(32767, abs=1)
|
||||
assert peak(tick_wav(gain=0.25)) == pytest.approx(32767 / 4, abs=1)
|
||||
@@ -207,7 +207,7 @@ async def test_playing_a_figure_album_reuses_the_figure_playlist(
|
||||
await client.post("/api/play", json={"album_id": album["id"]})
|
||||
await sim.bus.drain()
|
||||
|
||||
assert sim.player.playlist is sim.app.playlists["fuchs"]
|
||||
assert sim.player.playlist is sim.app.playlists["fuchs"][0]
|
||||
|
||||
|
||||
async def test_playing_an_unknown_album_is_a_404(client: Client) -> None:
|
||||
|
||||
Reference in New Issue
Block a user