cloudspin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudspin/__init__.py +3 -0
- cloudspin/__main__.py +4 -0
- cloudspin/app.py +497 -0
- cloudspin/canvas.py +321 -0
- cloudspin/cli.py +98 -0
- cloudspin/color.py +78 -0
- cloudspin/config.py +105 -0
- cloudspin/library.py +140 -0
- cloudspin/mood.py +245 -0
- cloudspin/player.py +447 -0
- cloudspin/screens.py +130 -0
- cloudspin/sky.py +829 -0
- cloudspin/songlook.py +116 -0
- cloudspin/spectrum.py +83 -0
- cloudspin/themes.py +100 -0
- cloudspin/vinyl.py +182 -0
- cloudspin/weather.py +310 -0
- cloudspin/widgets.py +750 -0
- cloudspin-0.1.0.dist-info/METADATA +206 -0
- cloudspin-0.1.0.dist-info/RECORD +23 -0
- cloudspin-0.1.0.dist-info/WHEEL +4 -0
- cloudspin-0.1.0.dist-info/entry_points.txt +2 -0
- cloudspin-0.1.0.dist-info/licenses/LICENSE +21 -0
cloudspin/library.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Finding music on disk and reading what it says about itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .player import Track
|
|
10
|
+
|
|
11
|
+
AUDIO_SUFFIXES = {
|
|
12
|
+
".mp3", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".m4b", ".aac", ".wav",
|
|
13
|
+
".wma", ".aiff", ".aif", ".ape", ".wv", ".mka",
|
|
14
|
+
} # fmt: skip
|
|
15
|
+
COVER_NAMES = ("cover", "folder", "front", "album", "artwork")
|
|
16
|
+
COVER_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _natural(text: str) -> list:
|
|
20
|
+
parts: list = []
|
|
21
|
+
number = ""
|
|
22
|
+
word = ""
|
|
23
|
+
for ch in text.lower():
|
|
24
|
+
if ch.isdigit():
|
|
25
|
+
if word:
|
|
26
|
+
parts.append(word)
|
|
27
|
+
word = ""
|
|
28
|
+
number += ch
|
|
29
|
+
else:
|
|
30
|
+
if number:
|
|
31
|
+
parts.append(int(number))
|
|
32
|
+
number = ""
|
|
33
|
+
word += ch
|
|
34
|
+
if number:
|
|
35
|
+
parts.append(int(number))
|
|
36
|
+
if word:
|
|
37
|
+
parts.append(word)
|
|
38
|
+
# Keep ints and strings comparable by tagging them.
|
|
39
|
+
return [(0, p) if isinstance(p, int) else (1, p) for p in parts]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scan(sources: list[str]) -> list[Track]:
|
|
43
|
+
"""Collect tracks from files, folders and stream URLs, in a natural order."""
|
|
44
|
+
tracks: list[Track] = []
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
|
|
47
|
+
def add(path: Path) -> None:
|
|
48
|
+
key = str(path)
|
|
49
|
+
if key not in seen and path.suffix.lower() in AUDIO_SUFFIXES:
|
|
50
|
+
seen.add(key)
|
|
51
|
+
tracks.append(Track(path=key, title=path.stem))
|
|
52
|
+
|
|
53
|
+
for source in sources:
|
|
54
|
+
if "://" in source:
|
|
55
|
+
if source not in seen:
|
|
56
|
+
seen.add(source)
|
|
57
|
+
tracks.append(Track(path=source, title=source.split("://", 1)[1].rstrip("/") or source))
|
|
58
|
+
continue
|
|
59
|
+
root = Path(source).expanduser()
|
|
60
|
+
if root.is_file():
|
|
61
|
+
add(root.resolve())
|
|
62
|
+
elif root.is_dir():
|
|
63
|
+
for folder, folders, files in os.walk(root, followlinks=True):
|
|
64
|
+
folders[:] = sorted((d for d in folders if not d.startswith(".")), key=_natural)
|
|
65
|
+
for name in sorted(files, key=_natural):
|
|
66
|
+
if not name.startswith("."):
|
|
67
|
+
add(Path(folder, name).resolve())
|
|
68
|
+
return tracks
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _first(value) -> str:
|
|
72
|
+
if isinstance(value, (list, tuple)):
|
|
73
|
+
value = value[0] if value else ""
|
|
74
|
+
return str(value).strip() if value is not None else ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_tags(track: Track) -> bool:
|
|
78
|
+
"""Fill in title, artist, album and length from the file. True if anything was read."""
|
|
79
|
+
if track.is_stream:
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
import mutagen
|
|
83
|
+
|
|
84
|
+
audio = mutagen.File(track.path, easy=True)
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
if audio is None:
|
|
88
|
+
return False
|
|
89
|
+
tags = audio.tags or {}
|
|
90
|
+
try:
|
|
91
|
+
title = _first(tags.get("title"))
|
|
92
|
+
artist = _first(tags.get("artist")) or _first(tags.get("albumartist"))
|
|
93
|
+
album = _first(tags.get("album"))
|
|
94
|
+
genre = _first(tags.get("genre"))
|
|
95
|
+
except Exception:
|
|
96
|
+
title = artist = album = genre = ""
|
|
97
|
+
if title:
|
|
98
|
+
track.title = title
|
|
99
|
+
track.artist = artist or track.artist
|
|
100
|
+
track.album = album or track.album
|
|
101
|
+
track.genre = genre or track.genre
|
|
102
|
+
length = getattr(getattr(audio, "info", None), "length", 0.0) or 0.0
|
|
103
|
+
if length > 0:
|
|
104
|
+
track.duration = float(length)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cover_art(path: str) -> bytes | None:
|
|
109
|
+
"""The picture that goes with a track: embedded art first, then a cover file beside it."""
|
|
110
|
+
if "://" in path:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
import mutagen
|
|
114
|
+
from mutagen.flac import Picture
|
|
115
|
+
|
|
116
|
+
audio = mutagen.File(path)
|
|
117
|
+
if audio is not None:
|
|
118
|
+
pictures = getattr(audio, "pictures", None)
|
|
119
|
+
if pictures:
|
|
120
|
+
return bytes(pictures[0].data)
|
|
121
|
+
tags = audio.tags
|
|
122
|
+
if tags is not None:
|
|
123
|
+
for key in tags.keys():
|
|
124
|
+
if str(key).startswith("APIC"):
|
|
125
|
+
return bytes(tags[key].data)
|
|
126
|
+
if "covr" in tags and tags["covr"]:
|
|
127
|
+
return bytes(tags["covr"][0])
|
|
128
|
+
blocks = tags.get("metadata_block_picture")
|
|
129
|
+
if blocks:
|
|
130
|
+
return bytes(Picture(base64.b64decode(blocks[0])).data)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
try:
|
|
134
|
+
folder = Path(path).parent
|
|
135
|
+
for entry in sorted(folder.iterdir()):
|
|
136
|
+
if entry.suffix.lower() in COVER_SUFFIXES and entry.stem.lower() in COVER_NAMES:
|
|
137
|
+
return entry.read_bytes()
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
return None
|
cloudspin/mood.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Which sky suits a song.
|
|
2
|
+
|
|
3
|
+
Three sources, in order of trust:
|
|
4
|
+
|
|
5
|
+
1. your own ``[moods]`` rules from the config file,
|
|
6
|
+
2. words in the genre tag, title and album ("phonk", "lofi", "rain", ...),
|
|
7
|
+
3. a short listen: 30 seconds decoded with ffmpeg and measured for loudness,
|
|
8
|
+
tempo, bass weight and brightness. This last one is a rough guess.
|
|
9
|
+
|
|
10
|
+
If none of them says anything, the sky goes back to showing the real weather.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from array import array
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from .sky import Daylight, Weather
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Scene:
|
|
28
|
+
key: str
|
|
29
|
+
label: str
|
|
30
|
+
weather: Weather
|
|
31
|
+
daylight: Daylight
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SCENES: dict[str, Scene] = {
|
|
35
|
+
scene.key: scene
|
|
36
|
+
for scene in (
|
|
37
|
+
Scene("storm", "Thunderstorm", Weather(1.0, rain=0.9, storm=1.0, wind=24), Daylight(-0.85, 1.0, 0.4)),
|
|
38
|
+
Scene("rain", "Rain", Weather(0.95, rain=0.7, wind=14), Daylight(0.2, 0.8, 0.0)),
|
|
39
|
+
Scene("evening", "Evening", Weather(0.3, wind=5), Daylight(0.09, 0.9, 0.0)),
|
|
40
|
+
Scene("night", "Starry night", Weather(0.08, wind=3), Daylight(-0.9, 1.0, 0.38)),
|
|
41
|
+
Scene("day", "Sunny day", Weather(0.22, wind=9), Daylight(0.95, 0.42, 1.0)),
|
|
42
|
+
Scene("dawn", "Dawn", Weather(0.3, wind=4), Daylight(0.07, 0.08, 1.0)),
|
|
43
|
+
Scene("snow", "Snow", Weather(0.92, snow=0.7, wind=5), Daylight(0.3, 0.75, 0.0)),
|
|
44
|
+
Scene("fog", "Fog", Weather(0.5, fog=1.0, wind=2), Daylight(0.25, 0.2, 1.0)),
|
|
45
|
+
Scene("overcast", "Overcast", Weather(0.96, wind=12), Daylight(0.6, 0.5, 1.0)),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Mood:
|
|
52
|
+
scene: str # a key of SCENES
|
|
53
|
+
why: str # short, for the caption on the sky
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# A trailing * matches any ending ("melanchol*" matches "melancholy").
|
|
57
|
+
_GENRES: list[tuple[str, tuple[str, ...]]] = [
|
|
58
|
+
("storm", ("phonk", "metal", "metalcore", "deathcore", "hardstyle", "hardcore", "drill", "dubstep",
|
|
59
|
+
"drum and bass", "drum & bass", "dnb", "industrial", "rage", "grindcore", "punk",
|
|
60
|
+
"breakcore", "gabber", "riddim", "hard rock", "thrash", "screamo")),
|
|
61
|
+
("rain", ("blues", "emo", "sad", "melanchol*", "grunge", "slowcore", "doom")),
|
|
62
|
+
("snow", ("christmas", "xmas", "holiday", "carol*", "winter")),
|
|
63
|
+
("fog", ("shoegaze", "dream pop", "dreampop", "post-rock", "post rock", "darkwave", "drone",
|
|
64
|
+
"ethereal", "witch house")),
|
|
65
|
+
("night", ("synthwave", "retrowave", "vaporwave", "r&b", "rnb", "soul", "trip hop", "trip-hop",
|
|
66
|
+
"downtempo", "deep house", "techno", "trap", "nocturne", "chillwave", "future garage")),
|
|
67
|
+
("evening", ("lofi", "lo-fi", "lo fi", "chill", "chillout", "chillhop", "ambient", "acoustic", "piano",
|
|
68
|
+
"classical", "jazz", "bossa nova", "ballad", "singer-songwriter", "folk", "meditat*",
|
|
69
|
+
"easy listening", "soft rock", "instrumental", "soundtrack", "slowed")),
|
|
70
|
+
("dawn", ("gospel", "worship", "new age", "devotional", "bhajan")),
|
|
71
|
+
("day", ("pop", "dance", "edm", "house", "disco", "funk", "reggae", "latin", "afrobeat*", "k-pop",
|
|
72
|
+
"j-pop", "ska", "country", "indie", "rock", "hip hop", "hip-hop", "rap", "bollywood",
|
|
73
|
+
"punjabi", "electronic")),
|
|
74
|
+
] # fmt: skip
|
|
75
|
+
|
|
76
|
+
# Only words that plainly name a sky are trusted in titles and album names.
|
|
77
|
+
_TITLES: list[tuple[str, tuple[str, ...]]] = [
|
|
78
|
+
("storm", ("phonk", "thunder*", "storm*", "lightning", "hurricane", "tempest")),
|
|
79
|
+
("rain", ("rain*", "monsoon", "petrichor", "drizzle", "downpour", "tears", "umbrella")),
|
|
80
|
+
("snow", ("snow*", "winter", "christmas", "xmas", "frost*", "blizzard", "icicle*")),
|
|
81
|
+
("fog", ("fog*", "mist*", "haze", "hazy", "smoke")),
|
|
82
|
+
("night", ("night*", "midnight", "moon*", "stars", "starlight", "nocturne", "insomnia", "3am", "2am")),
|
|
83
|
+
("evening", ("sunset*", "evening*", "dusk", "twilight", "golden hour", "lofi", "lo-fi", "slowed")),
|
|
84
|
+
("dawn", ("sunrise", "morning*", "dawn", "daybreak")),
|
|
85
|
+
("day", ("summer*", "sunshine", "sunny", "daylight", "sunlight", "sunflower*")),
|
|
86
|
+
] # fmt: skip
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _pattern(word: str) -> re.Pattern[str]:
|
|
90
|
+
stem = word.rstrip("*")
|
|
91
|
+
tail = r"[a-z0-9]*" if word.endswith("*") else ""
|
|
92
|
+
return re.compile(r"(?<![a-z0-9])" + re.escape(stem) + tail + r"(?![a-z0-9])")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_GENRE_RULES = [(scene, word, _pattern(word)) for scene, words in _GENRES for word in words]
|
|
96
|
+
_TITLE_RULES = [(scene, word, _pattern(word)) for scene, words in _TITLES for word in words]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def from_words(
|
|
100
|
+
title: str, artist: str = "", album: str = "", genre: str = "", rules: dict[str, str] | None = None
|
|
101
|
+
) -> Mood | None:
|
|
102
|
+
"""Guess a sky from what the song is called and how it is tagged."""
|
|
103
|
+
genre_text = genre.lower()
|
|
104
|
+
name_text = f"{title} {album}".lower()
|
|
105
|
+
everything = f"{genre} {title} {artist} {album}".lower()
|
|
106
|
+
for word, scene in (rules or {}).items():
|
|
107
|
+
if scene in SCENES and word.strip() and _pattern(word.strip().lower()).search(everything):
|
|
108
|
+
return Mood(scene, f"your rule for “{word.strip()}”")
|
|
109
|
+
if genre_text.strip():
|
|
110
|
+
# Rules run in order, so "dream pop" is caught as fog before "pop" can claim it.
|
|
111
|
+
for scene, word, pattern in _GENRE_RULES:
|
|
112
|
+
if pattern.search(genre_text):
|
|
113
|
+
return Mood(scene, f"tagged {genre.strip()}")
|
|
114
|
+
for scene, word, pattern in _TITLE_RULES:
|
|
115
|
+
found = pattern.search(name_text)
|
|
116
|
+
if found:
|
|
117
|
+
return Mood(scene, f"“{found.group(0)}” in the name")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# -- listening ---------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Features:
|
|
126
|
+
loudness: float # RMS level in dBFS, about -35 (quiet) to -8 (crushed)
|
|
127
|
+
tempo: float # beats per minute, a rough estimate
|
|
128
|
+
onsets: float # sharp attacks per second
|
|
129
|
+
bass: float # share of energy below ~150 Hz, 0..1
|
|
130
|
+
brightness: float # zero crossings per sample, 0..0.5
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def intensity(self) -> float:
|
|
134
|
+
loud = min(1.0, max(0.0, (self.loudness + 28.0) / 18.0))
|
|
135
|
+
busy = min(1.0, self.onsets / 5.0)
|
|
136
|
+
fast = min(1.0, max(0.0, (self.tempo - 70.0) / 90.0))
|
|
137
|
+
return 0.5 * loud + 0.3 * busy + 0.2 * fast
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def measure(samples, rate: int = 8000) -> Features | None:
|
|
141
|
+
"""Measure mono 16-bit samples. Needs at least a few seconds of sound."""
|
|
142
|
+
frame = max(1, rate // 100)
|
|
143
|
+
frames = len(samples) // frame
|
|
144
|
+
if frames < 300:
|
|
145
|
+
return None
|
|
146
|
+
k = 1.0 - math.exp(-math.tau * 150.0 / rate)
|
|
147
|
+
low = 0.0
|
|
148
|
+
low_energy = 0.0
|
|
149
|
+
total = 0.0
|
|
150
|
+
crossings = 0
|
|
151
|
+
positive = True
|
|
152
|
+
energies: list[float] = []
|
|
153
|
+
for f in range(frames):
|
|
154
|
+
acc = 0.0
|
|
155
|
+
for x in samples[f * frame : (f + 1) * frame]:
|
|
156
|
+
acc += x * x
|
|
157
|
+
low += k * (x - low)
|
|
158
|
+
low_energy += low * low
|
|
159
|
+
if (x >= 0) != positive:
|
|
160
|
+
positive = not positive
|
|
161
|
+
crossings += 1
|
|
162
|
+
energies.append(acc / frame)
|
|
163
|
+
total += acc
|
|
164
|
+
count = frames * frame
|
|
165
|
+
mean_square = total / count
|
|
166
|
+
if mean_square < 1.0:
|
|
167
|
+
return None # silence
|
|
168
|
+
loudness = 10.0 * math.log10(mean_square / (32768.0 * 32768.0))
|
|
169
|
+
|
|
170
|
+
# Attacks: sudden rises in level, measured on lightly smoothed frames so a
|
|
171
|
+
# steady tone's ripple doesn't count as a drum hit.
|
|
172
|
+
smooth = [(a + b + c) / 3.0 for a, b, c in zip(energies, energies[1:], energies[2:])]
|
|
173
|
+
logs = [math.log(e + 1.0) for e in smooth]
|
|
174
|
+
flux = [0.0] + [max(0.0, b - a) for a, b in zip(logs, logs[1:])]
|
|
175
|
+
mean_flux = sum(flux) / len(flux)
|
|
176
|
+
spread = math.sqrt(sum((v - mean_flux) ** 2 for v in flux) / len(flux))
|
|
177
|
+
gate = max(0.3, mean_flux + 1.2 * spread)
|
|
178
|
+
peaks = sum(
|
|
179
|
+
1 for i in range(1, len(flux) - 1) if flux[i] > gate and flux[i] >= flux[i - 1] and flux[i] > flux[i + 1]
|
|
180
|
+
)
|
|
181
|
+
onsets = peaks / (len(flux) / 100.0)
|
|
182
|
+
|
|
183
|
+
# Tempo: the shortest lag at which the attack pattern lines up with itself.
|
|
184
|
+
tempo = 0.0
|
|
185
|
+
if onsets >= 0.4:
|
|
186
|
+
centred = [v - mean_flux for v in flux]
|
|
187
|
+
scores = {
|
|
188
|
+
lag: sum(a * b for a, b in zip(centred, centred[lag:])) / (len(centred) - lag)
|
|
189
|
+
for lag in range(30, 101) # 200 down to 60 beats per minute
|
|
190
|
+
}
|
|
191
|
+
best = max(scores.values())
|
|
192
|
+
if best > 0.0:
|
|
193
|
+
lag = next(lag for lag in sorted(scores) if scores[lag] >= 0.85 * best)
|
|
194
|
+
tempo = 6000.0 / lag
|
|
195
|
+
|
|
196
|
+
return Features(
|
|
197
|
+
loudness=loudness,
|
|
198
|
+
tempo=tempo,
|
|
199
|
+
onsets=onsets,
|
|
200
|
+
bass=min(1.0, low_energy / total) if total else 0.0,
|
|
201
|
+
brightness=crossings / count,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def listen(path: str, ffmpeg: str | None = None) -> Features | None:
|
|
206
|
+
"""Decode half a minute from the middle of a file and measure it."""
|
|
207
|
+
if "://" in path:
|
|
208
|
+
return None
|
|
209
|
+
ffmpeg = ffmpeg or shutil.which("ffmpeg")
|
|
210
|
+
if not ffmpeg:
|
|
211
|
+
return None
|
|
212
|
+
raw = b""
|
|
213
|
+
for start in (40, 0):
|
|
214
|
+
try:
|
|
215
|
+
done = subprocess.run(
|
|
216
|
+
[ffmpeg, "-v", "error", "-nostdin", "-ss", str(start), "-t", "30", "-i", path,
|
|
217
|
+
"-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
|
|
218
|
+
stdin=subprocess.DEVNULL, capture_output=True, timeout=25,
|
|
219
|
+
) # fmt: skip
|
|
220
|
+
except (OSError, subprocess.SubprocessError):
|
|
221
|
+
return None
|
|
222
|
+
raw = done.stdout
|
|
223
|
+
if len(raw) >= 8000 * 2 * 8:
|
|
224
|
+
break
|
|
225
|
+
samples = array("h")
|
|
226
|
+
samples.frombytes(raw[: len(raw) // 2 * 2])
|
|
227
|
+
if sys.byteorder == "big":
|
|
228
|
+
samples.byteswap()
|
|
229
|
+
return measure(samples, 8000)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def from_features(features: Features) -> Mood:
|
|
233
|
+
"""Turn measurements into a sky. Heavy and loud storms; quiet and slow settles."""
|
|
234
|
+
intensity = features.intensity
|
|
235
|
+
if intensity >= 0.68:
|
|
236
|
+
if features.bass >= 0.42:
|
|
237
|
+
return Mood("storm", "loud, fast and heavy on bass")
|
|
238
|
+
return Mood("day", "bright and energetic")
|
|
239
|
+
if intensity >= 0.48:
|
|
240
|
+
if features.brightness < 0.06 and features.bass >= 0.35:
|
|
241
|
+
return Mood("rain", "dark and steady")
|
|
242
|
+
return Mood("day", "upbeat")
|
|
243
|
+
if intensity >= 0.27:
|
|
244
|
+
return Mood("evening", "calm")
|
|
245
|
+
return Mood("night", "quiet and slow")
|