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/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")