From 7659bbae5c06395faab6df85c83087e8db5da2c6 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:31:09 -0300 Subject: [PATCH 01/27] feat(SimpleDjIA): add project metadata --- SimpleDjIA/pyproject.toml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 SimpleDjIA/pyproject.toml diff --git a/SimpleDjIA/pyproject.toml b/SimpleDjIA/pyproject.toml new file mode 100644 index 0000000..a34d785 --- /dev/null +++ b/SimpleDjIA/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "simple-djia" +version = "0.1.0" +description = "Open-source human-centered ambient DJ and pianist playlist engine." +readme = "README.md" +requires-python = ">=3.10" +authors = [{name = "Sidiney Rodrigues"}] +license = {text = "MIT"} +keywords = ["music", "playlist", "linux", "dj", "piano", "wellbeing"] +classifiers = [ + "Programming Language :: Python :: 3", + "Operating System :: POSIX :: Linux", + "License :: OSI Approved :: MIT License", +] + +[project.scripts] +simple-djia = "simple_djia.cli:main" +simple-djia-gui = "simple_djia.gui:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] From 3345ceca9bbf9581c6a4d510ccbf8512dde9ce95 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:31:15 -0300 Subject: [PATCH 02/27] feat(SimpleDjIA): add package --- SimpleDjIA/src/simple_djia/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/__init__.py diff --git a/SimpleDjIA/src/simple_djia/__init__.py b/SimpleDjIA/src/simple_djia/__init__.py new file mode 100644 index 0000000..cd61ccf --- /dev/null +++ b/SimpleDjIA/src/simple_djia/__init__.py @@ -0,0 +1,3 @@ +"""Simple DjIA: human-centered music programming for open platforms.""" + +__version__ = "0.1.0" From 57f71b59c358792c244baa06db53f611bfaf9bc6 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:31:27 -0300 Subject: [PATCH 03/27] feat(SimpleDjIA): add music and scene models --- SimpleDjIA/src/simple_djia/models.py | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/models.py diff --git a/SimpleDjIA/src/simple_djia/models.py b/SimpleDjIA/src/simple_djia/models.py new file mode 100644 index 0000000..21fe83d --- /dev/null +++ b/SimpleDjIA/src/simple_djia/models.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass(frozen=True) +class Track: + path: Path + title: str + artist: str = "Unknown" + genre: str = "unknown" + bpm: float = 100.0 + energy: float = 0.5 + valence: float = 0.5 + acousticness: float = 0.5 + danceability: float = 0.5 + vocal_presence: float = 0.5 + familiarity: float = 0.5 + tags: tuple[str, ...] = () + + def __post_init__(self) -> None: + for name in ("energy", "valence", "acousticness", "danceability", "vocal_presence", "familiarity"): + value = getattr(self, name) + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be between 0 and 1") + if not 20 <= self.bpm <= 300: + raise ValueError("bpm must be between 20 and 300") + + +@dataclass(frozen=True) +class SceneProfile: + key: str + name: str + description: str + target_bpm: float + bpm_tolerance: float + targets: dict[str, float] + weights: dict[str, float] + preferred_tags: tuple[str, ...] = () + avoid_tags: tuple[str, ...] = () + max_vocal_presence: float | None = None + transition_limit: float = 0.35 + + +@dataclass(frozen=True) +class RankedTrack: + track: Track + score: float + reasons: tuple[str, ...] = field(default_factory=tuple) From 8bf0818be0d73f99870de5be7a1b905f792dcbba Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:31:44 -0300 Subject: [PATCH 04/27] feat(SimpleDjIA): add human-centered scene profiles --- SimpleDjIA/src/simple_djia/profiles.py | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/profiles.py diff --git a/SimpleDjIA/src/simple_djia/profiles.py b/SimpleDjIA/src/simple_djia/profiles.py new file mode 100644 index 0000000..27e33e5 --- /dev/null +++ b/SimpleDjIA/src/simple_djia/profiles.py @@ -0,0 +1,63 @@ +from .models import SceneProfile + + +SCENES: dict[str, SceneProfile] = { + "restaurant_piano": SceneProfile( + key="restaurant_piano", + name="Pianista no restaurante", + description="Fundo elegante, conversável e pouco intrusivo; prioriza piano, acústico e transições suaves.", + target_bpm=82, + bpm_tolerance=26, + targets={"energy": 0.30, "valence": 0.58, "acousticness": 0.90, "danceability": 0.25, "familiarity": 0.55}, + weights={"energy": 1.5, "valence": 0.8, "acousticness": 1.6, "danceability": 0.5, "familiarity": 0.7}, + preferred_tags=("piano", "instrumental", "acoustic", "jazz", "bossa"), + avoid_tags=("aggressive", "explicit", "distorted"), + max_vocal_presence=0.35, + transition_limit=0.22, + ), + "mega_party_dj": SceneProfile( + key="mega_party_dj", + name="DJ de festa intensa", + description="Alta energia e dança, com progressão controlada para evitar saltos bruscos de sensação.", + target_bpm=126, + bpm_tolerance=18, + targets={"energy": 0.88, "valence": 0.78, "acousticness": 0.12, "danceability": 0.92, "familiarity": 0.65}, + weights={"energy": 1.8, "valence": 0.8, "acousticness": 0.5, "danceability": 2.0, "familiarity": 0.5}, + preferred_tags=("dance", "house", "electronic", "party"), + avoid_tags=("sleep", "meditation"), + transition_limit=0.30, + ), + "calm_connection": SceneProfile( + key="calm_connection", + name="Conexão calma", + description="Curadoria de baixa sobrecarga inspirada em escuta humana e vínculo; não substitui musicoterapia clínica.", + target_bpm=72, + bpm_tolerance=22, + targets={"energy": 0.22, "valence": 0.62, "acousticness": 0.78, "danceability": 0.18, "familiarity": 0.62}, + weights={"energy": 1.7, "valence": 1.2, "acousticness": 1.2, "danceability": 0.4, "familiarity": 1.0}, + preferred_tags=("gentle", "acoustic", "piano", "ambient", "instrumental"), + avoid_tags=("aggressive", "explicit", "harsh"), + max_vocal_presence=0.55, + transition_limit=0.18, + ), + "focus_background": SceneProfile( + key="focus_background", + name="Fundo para foco", + description="Música previsível e discreta, com preferência por instrumental e baixa presença vocal.", + target_bpm=88, + bpm_tolerance=25, + targets={"energy": 0.35, "valence": 0.52, "acousticness": 0.62, "danceability": 0.20, "familiarity": 0.45}, + weights={"energy": 1.2, "valence": 0.5, "acousticness": 0.8, "danceability": 0.5, "familiarity": 0.4}, + preferred_tags=("instrumental", "ambient", "piano", "lofi"), + avoid_tags=("aggressive", "explicit"), + max_vocal_presence=0.25, + transition_limit=0.20, + ), +} + + +def get_scene(key: str) -> SceneProfile: + try: + return SCENES[key] + except KeyError as exc: + raise ValueError(f"Unknown scene: {key}") from exc From 04ef6186239501cb591290f2c4ad5c99c73babf7 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:31:52 -0300 Subject: [PATCH 05/27] feat(SimpleDjIA): add JSON music library loader --- SimpleDjIA/src/simple_djia/library.py | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/library.py diff --git a/SimpleDjIA/src/simple_djia/library.py b/SimpleDjIA/src/simple_djia/library.py new file mode 100644 index 0000000..2a6cd50 --- /dev/null +++ b/SimpleDjIA/src/simple_djia/library.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from .models import Track + + +def load_library(path: str | Path) -> list[Track]: + source = Path(path) + data = json.loads(source.read_text(encoding="utf-8")) + if not isinstance(data, list): + raise ValueError("library JSON must contain a list") + tracks: list[Track] = [] + for item in data: + raw_path = Path(item["path"]).expanduser() + if not raw_path.is_absolute(): + raw_path = (source.parent / raw_path).resolve() + tracks.append(Track( + path=raw_path, + title=item.get("title", raw_path.stem), + artist=item.get("artist", "Unknown"), + genre=item.get("genre", "unknown"), + bpm=float(item.get("bpm", 100)), + energy=float(item.get("energy", 0.5)), + valence=float(item.get("valence", 0.5)), + acousticness=float(item.get("acousticness", 0.5)), + danceability=float(item.get("danceability", 0.5)), + vocal_presence=float(item.get("vocal_presence", 0.5)), + familiarity=float(item.get("familiarity", 0.5)), + tags=tuple(item.get("tags", [])), + )) + return tracks From 12a75ba4f40c47c5fbfcac2306995f6a857bd386 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:06 -0300 Subject: [PATCH 06/27] feat(SimpleDjIA): add transparent human-centered selector --- SimpleDjIA/src/simple_djia/selector.py | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/selector.py diff --git a/SimpleDjIA/src/simple_djia/selector.py b/SimpleDjIA/src/simple_djia/selector.py new file mode 100644 index 0000000..783e69f --- /dev/null +++ b/SimpleDjIA/src/simple_djia/selector.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from .models import RankedTrack, SceneProfile, Track + +FEATURES = ("energy", "valence", "acousticness", "danceability", "familiarity") + + +def score_track(track: Track, scene: SceneProfile) -> RankedTrack: + tags = set(track.tags) + if tags.intersection(scene.avoid_tags): + return RankedTrack(track, -999.0, ("blocked by avoid tag",)) + if scene.max_vocal_presence is not None and track.vocal_presence > scene.max_vocal_presence: + return RankedTrack(track, -999.0, ("too much vocal presence for scene",)) + + score = 1.0 + reasons: list[str] = [] + bpm_distance = abs(track.bpm - scene.target_bpm) / max(scene.bpm_tolerance, 1) + score -= min(bpm_distance, 2.0) * 0.7 + if bpm_distance <= 0.5: + reasons.append("tempo fits scene") + + for feature in FEATURES: + target = scene.targets.get(feature, 0.5) + weight = scene.weights.get(feature, 1.0) + distance = abs(getattr(track, feature) - target) + score -= distance * weight + if distance <= 0.15: + reasons.append(f"{feature} near target") + + preferred = tags.intersection(scene.preferred_tags) + score += 0.18 * len(preferred) + if preferred: + reasons.append("preferred tags: " + ", ".join(sorted(preferred))) + return RankedTrack(track, score, tuple(reasons)) + + +def _transition_cost(a: Track, b: Track) -> float: + bpm = min(abs(a.bpm - b.bpm) / 60.0, 1.0) + energy = abs(a.energy - b.energy) + valence = abs(a.valence - b.valence) + return 0.45 * bpm + 0.35 * energy + 0.20 * valence + + +def build_playlist(tracks: list[Track], scene: SceneProfile, limit: int = 20) -> list[RankedTrack]: + """Rank transparently, then sequence with artist diversity and gradual transitions.""" + pool = [r for r in (score_track(t, scene) for t in tracks) if r.score > -900] + pool.sort(key=lambda item: item.score, reverse=True) + if not pool: + return [] + + result: list[RankedTrack] = [pool.pop(0)] + while pool and len(result) < limit: + previous = result[-1].track + candidates = sorted( + pool, + key=lambda item: ( + item.track.artist == previous.artist, + _transition_cost(previous, item.track) > scene.transition_limit, + _transition_cost(previous, item.track), + -item.score, + ), + ) + chosen = candidates[0] + result.append(chosen) + pool.remove(chosen) + return result From 408403cd1262959bce36fa16d4a5b3d181e0e987 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:15 -0300 Subject: [PATCH 07/27] feat(SimpleDjIA): add open-platform player backend --- SimpleDjIA/src/simple_djia/player.py | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/player.py diff --git a/SimpleDjIA/src/simple_djia/player.py b/SimpleDjIA/src/simple_djia/player.py new file mode 100644 index 0000000..401452d --- /dev/null +++ b/SimpleDjIA/src/simple_djia/player.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import shutil +import subprocess + + +@dataclass(frozen=True) +class PlayerBackend: + name: str + executable: str + args: tuple[str, ...] + + +BACKENDS = ( + PlayerBackend("mpv", "mpv", ("--no-video", "--really-quiet")), + PlayerBackend("vlc", "cvlc", ("--play-and-exit", "--quiet")), + PlayerBackend("ffplay", "ffplay", ("-nodisp", "-autoexit", "-loglevel", "error")), +) + + +def detect_backend() -> PlayerBackend | None: + for backend in BACKENDS: + if shutil.which(backend.executable): + return backend + return None + + +def play(path: str | Path, backend: PlayerBackend | None = None) -> subprocess.Popen: + selected = backend or detect_backend() + if selected is None: + raise RuntimeError("No supported player found. Install mpv, VLC/cvlc or ffplay.") + audio = Path(path) + if not audio.exists(): + raise FileNotFoundError(audio) + return subprocess.Popen([selected.executable, *selected.args, str(audio)]) From b9acdb5e51a4f37a243f8a99ad5f04c0473be26d Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:23 -0300 Subject: [PATCH 08/27] feat(SimpleDjIA): add session orchestration --- SimpleDjIA/src/simple_djia/session.py | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/session.py diff --git a/SimpleDjIA/src/simple_djia/session.py b/SimpleDjIA/src/simple_djia/session.py new file mode 100644 index 0000000..8ce3338 --- /dev/null +++ b/SimpleDjIA/src/simple_djia/session.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +import time + +from .models import RankedTrack, SceneProfile, Track +from .player import play +from .selector import build_playlist + + +@dataclass +class Session: + scene: SceneProfile + playlist: list[RankedTrack] + current_index: int = 0 + + @property + def current(self) -> RankedTrack | None: + if not self.playlist: + return None + return self.playlist[self.current_index] + + def next(self) -> RankedTrack | None: + if self.current_index + 1 >= len(self.playlist): + return None + self.current_index += 1 + return self.current + + +def create_session(tracks: list[Track], scene: SceneProfile, limit: int = 20) -> Session: + return Session(scene=scene, playlist=build_playlist(tracks, scene, limit)) + + +def play_session(session: Session) -> None: + """Play the curated list sequentially with the detected open-source backend.""" + for index, ranked in enumerate(session.playlist): + session.current_index = index + process = play(ranked.track.path) + while process.poll() is None: + time.sleep(0.25) From 245c1d67fad6e498aaeef3f582aaaf0930b48e7b Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:32 -0300 Subject: [PATCH 09/27] feat(SimpleDjIA): add command-line menu --- SimpleDjIA/src/simple_djia/cli.py | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/cli.py diff --git a/SimpleDjIA/src/simple_djia/cli.py b/SimpleDjIA/src/simple_djia/cli.py new file mode 100644 index 0000000..c71ecce --- /dev/null +++ b/SimpleDjIA/src/simple_djia/cli.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import argparse + +from .library import load_library +from .profiles import SCENES, get_scene +from .session import create_session, play_session + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="simple-djia", description="Human-centered ambient DJ for open platforms") + p.add_argument("library", help="JSON music library") + p.add_argument("--scene", choices=tuple(SCENES), default="restaurant_piano") + p.add_argument("--limit", type=int, default=12) + p.add_argument("--play", action="store_true", help="play the generated list using mpv/VLC/ffplay") + return p + + +def main() -> None: + args = parser().parse_args() + tracks = load_library(args.library) + scene = get_scene(args.scene) + session = create_session(tracks, scene, args.limit) + print(f"\nSimple DjIA — {scene.name}\n{scene.description}\n") + if not session.playlist: + print("No compatible tracks found for this scene.") + raise SystemExit(2) + for i, ranked in enumerate(session.playlist, 1): + t = ranked.track + why = "; ".join(ranked.reasons[:3]) or "balanced scene fit" + print(f"{i:02d}. {t.artist} — {t.title} | {t.bpm:g} BPM | score {ranked.score:.2f} | {why}") + if args.play: + play_session(session) + + +if __name__ == "__main__": + main() From f8e166b64edb73d01d435a9b1eab372e2d4f8af9 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:44 -0300 Subject: [PATCH 10/27] feat(SimpleDjIA): add Tkinter interface --- SimpleDjIA/src/simple_djia/gui.py | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 SimpleDjIA/src/simple_djia/gui.py diff --git a/SimpleDjIA/src/simple_djia/gui.py b/SimpleDjIA/src/simple_djia/gui.py new file mode 100644 index 0000000..b6f2d40 --- /dev/null +++ b/SimpleDjIA/src/simple_djia/gui.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +from .library import load_library +from .profiles import SCENES, get_scene +from .session import create_session + + +class App(ttk.Frame): + def __init__(self, root: tk.Tk) -> None: + super().__init__(root, padding=16) + self.root = root + self.root.title("Simple DjIA") + self.grid(sticky="nsew") + self.columnconfigure(1, weight=1) + self.library = tk.StringVar() + self.scene = tk.StringVar(value="restaurant_piano") + self.limit = tk.IntVar(value=12) + self._build() + + def _build(self) -> None: + ttk.Label(self, text="Simple DjIA", font=("TkDefaultFont", 20, "bold")).grid(row=0, column=0, columnspan=3, sticky="w") + ttk.Label(self, text="Curadoria musical orientada por contexto humano, com critérios transparentes.").grid(row=1, column=0, columnspan=3, sticky="w", pady=(0, 14)) + ttk.Label(self, text="Biblioteca JSON").grid(row=2, column=0, sticky="w") + ttk.Entry(self, textvariable=self.library).grid(row=2, column=1, sticky="ew", padx=8) + ttk.Button(self, text="Abrir", command=self._choose).grid(row=2, column=2) + ttk.Label(self, text="Comportamento").grid(row=3, column=0, sticky="w", pady=8) + ttk.Combobox(self, textvariable=self.scene, values=tuple(SCENES), state="readonly").grid(row=3, column=1, columnspan=2, sticky="ew", padx=(8, 0)) + ttk.Label(self, text="Faixas").grid(row=4, column=0, sticky="w") + ttk.Spinbox(self, from_=1, to=100, textvariable=self.limit).grid(row=4, column=1, columnspan=2, sticky="ew", padx=(8, 0)) + ttk.Button(self, text="Criar playlist humana", command=self._generate).grid(row=5, column=0, columnspan=3, sticky="ew", pady=12) + self.output = tk.Text(self, height=20, width=80, wrap="word") + self.output.grid(row=6, column=0, columnspan=3, sticky="nsew") + self.rowconfigure(6, weight=1) + + def _choose(self) -> None: + filename = filedialog.askopenfilename(filetypes=(("JSON", "*.json"), ("Todos", "*.*"))) + if filename: + self.library.set(filename) + + def _generate(self) -> None: + try: + scene = get_scene(self.scene.get()) + session = create_session(load_library(self.library.get()), scene, self.limit.get()) + except Exception as exc: + messagebox.showerror("Simple DjIA", str(exc)) + return + self.output.delete("1.0", tk.END) + self.output.insert(tk.END, f"{scene.name}\n{scene.description}\n\n") + for i, ranked in enumerate(session.playlist, 1): + t = ranked.track + self.output.insert(tk.END, f"{i:02d}. {t.artist} — {t.title} | {t.bpm:g} BPM | {ranked.score:.2f}\n") + + +def main() -> None: + root = tk.Tk() + root.minsize(720, 560) + App(root) + root.mainloop() + + +if __name__ == "__main__": + main() From 67f8b6870f601dab08cbc2752c7deafe47810bf1 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:32:57 -0300 Subject: [PATCH 11/27] docs(SimpleDjIA): add example music library --- SimpleDjIA/examples/library.example.json | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 SimpleDjIA/examples/library.example.json diff --git a/SimpleDjIA/examples/library.example.json b/SimpleDjIA/examples/library.example.json new file mode 100644 index 0000000..6591dc9 --- /dev/null +++ b/SimpleDjIA/examples/library.example.json @@ -0,0 +1,30 @@ +[ + { + "path": "./music/restaurant-piano-01.ogg", + "title": "Mesa perto da janela", + "artist": "Example Artist", + "genre": "piano", + "bpm": 78, + "energy": 0.24, + "valence": 0.62, + "acousticness": 0.95, + "danceability": 0.18, + "vocal_presence": 0.0, + "familiarity": 0.45, + "tags": ["piano", "instrumental", "acoustic"] + }, + { + "path": "./music/party-house-01.ogg", + "title": "Open Floor", + "artist": "Example DJ", + "genre": "house", + "bpm": 126, + "energy": 0.9, + "valence": 0.82, + "acousticness": 0.08, + "danceability": 0.94, + "vocal_presence": 0.32, + "familiarity": 0.65, + "tags": ["dance", "house", "electronic", "party"] + } +] From 31ede2d68a3292fa80e92c872b0c69f3c2021698 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:33:06 -0300 Subject: [PATCH 12/27] test(SimpleDjIA): cover scene scoring and sequencing --- SimpleDjIA/tests/test_selector.py | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 SimpleDjIA/tests/test_selector.py diff --git a/SimpleDjIA/tests/test_selector.py b/SimpleDjIA/tests/test_selector.py new file mode 100644 index 0000000..e873bd0 --- /dev/null +++ b/SimpleDjIA/tests/test_selector.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from simple_djia.models import Track +from simple_djia.profiles import get_scene +from simple_djia.selector import build_playlist, score_track + + +def track(title, artist, bpm, energy, danceability, acousticness=0.5, valence=0.5, vocals=0.1, tags=()): + return Track(Path(f"/{title}.ogg"), title, artist, bpm=bpm, energy=energy, + danceability=danceability, acousticness=acousticness, + valence=valence, vocal_presence=vocals, familiarity=0.5, tags=tags) + + +def test_restaurant_prefers_acoustic_piano(): + scene = get_scene("restaurant_piano") + piano = track("piano", "A", 80, 0.28, 0.2, 0.95, 0.6, tags=("piano", "instrumental")) + club = track("club", "B", 128, 0.95, 0.95, 0.05, 0.8, tags=("house", "party")) + assert score_track(piano, scene).score > score_track(club, scene).score + + +def test_avoid_tag_blocks_track(): + scene = get_scene("calm_connection") + harsh = track("harsh", "A", 72, 0.2, 0.1, tags=("harsh",)) + assert score_track(harsh, scene).score < -900 + + +def test_playlist_avoids_same_artist_when_alternative_exists(): + scene = get_scene("mega_party_dj") + tracks = [ + track("one", "DJ A", 126, 0.9, 0.95, 0.1, 0.8, tags=("house",)), + track("two", "DJ A", 125, 0.88, 0.94, 0.1, 0.8, tags=("house",)), + track("three", "DJ B", 124, 0.86, 0.92, 0.1, 0.8, tags=("house",)), + ] + playlist = build_playlist(tracks, scene, 3) + assert playlist[0].track.artist != playlist[1].track.artist From d5444091fa857d2c7e8f03e8b27b5cabd53ee45a Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:33:14 -0300 Subject: [PATCH 13/27] test(SimpleDjIA): cover library loading --- SimpleDjIA/tests/test_library.py | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 SimpleDjIA/tests/test_library.py diff --git a/SimpleDjIA/tests/test_library.py b/SimpleDjIA/tests/test_library.py new file mode 100644 index 0000000..d04a6a8 --- /dev/null +++ b/SimpleDjIA/tests/test_library.py @@ -0,0 +1,35 @@ +import json + +from simple_djia.library import load_library + + +def test_load_library_resolves_relative_paths(tmp_path): + data = [{ + "path": "music/a.ogg", + "title": "A", + "artist": "Artist", + "bpm": 90, + "energy": 0.4, + "valence": 0.5, + "acousticness": 0.6, + "danceability": 0.3, + "vocal_presence": 0.1, + "familiarity": 0.4, + "tags": ["piano"] + }] + source = tmp_path / "library.json" + source.write_text(json.dumps(data), encoding="utf-8") + tracks = load_library(source) + assert tracks[0].path == (tmp_path / "music/a.ogg").resolve() + assert tracks[0].tags == ("piano",) + + +def test_library_must_be_list(tmp_path): + source = tmp_path / "library.json" + source.write_text("{}", encoding="utf-8") + try: + load_library(source) + except ValueError as exc: + assert "list" in str(exc) + else: + raise AssertionError("expected ValueError") From d28d13b9a82a37ff6c6bb38475abe296afdc86c7 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:33:22 -0300 Subject: [PATCH 14/27] test(SimpleDjIA): validate metadata constraints --- SimpleDjIA/tests/test_models.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 SimpleDjIA/tests/test_models.py diff --git a/SimpleDjIA/tests/test_models.py b/SimpleDjIA/tests/test_models.py new file mode 100644 index 0000000..8eeac20 --- /dev/null +++ b/SimpleDjIA/tests/test_models.py @@ -0,0 +1,14 @@ +from pathlib import Path +import pytest + +from simple_djia.models import Track + + +def test_feature_bounds_are_enforced(): + with pytest.raises(ValueError): + Track(Path("a.ogg"), "A", energy=1.2) + + +def test_bpm_bounds_are_enforced(): + with pytest.raises(ValueError): + Track(Path("a.ogg"), "A", bpm=500) From bf694ffc8d6097e04a39ebc126a6abdfe2f2d09a Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:33:31 -0300 Subject: [PATCH 15/27] ci(SimpleDjIA): add Linux compatibility matrix --- .github/workflows/simple-djia.yml | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/simple-djia.yml diff --git a/.github/workflows/simple-djia.yml b/.github/workflows/simple-djia.yml new file mode 100644 index 0000000..719235b --- /dev/null +++ b/.github/workflows/simple-djia.yml @@ -0,0 +1,41 @@ +name: Simple DjIA + +on: + push: + branches: [master, feature/simple-djia-v1] + paths: + - 'SimpleDjIA/**' + - '.github/workflows/simple-djia.yml' + pull_request: + branches: [master] + paths: + - 'SimpleDjIA/**' + - '.github/workflows/simple-djia.yml' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + defaults: + run: + working-directory: SimpleDjIA + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e . pytest + - name: Compile + run: python -m compileall -q src + - name: Tests + run: pytest -q + - name: Smoke CLI + run: simple-djia --help From 7fe35ddf64634fc0c677c9a56493a9f23dfc12c6 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:33:56 -0300 Subject: [PATCH 16/27] docs(SimpleDjIA): add complete project guide --- SimpleDjIA/README.md | 183 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 SimpleDjIA/README.md diff --git a/SimpleDjIA/README.md b/SimpleDjIA/README.md new file mode 100644 index 0000000..da8ecd3 --- /dev/null +++ b/SimpleDjIA/README.md @@ -0,0 +1,183 @@ +# Simple DjIA + +**Simple DjIA** is an open-source, human-centered music programming application for Linux and other open platforms. It behaves like a contextual musician: a discreet pianist in a restaurant, an energetic DJ at a party, a calm background selector, or a focus-oriented ambient curator. + +The project deliberately keeps musical decisions transparent. It does **not** claim to replace a human DJ, musician, therapist, or clinical music therapist. Its wellbeing-oriented profiles are inspired by human listening, context, familiarity, gradual change, and respect for the people in the room. + +## Philosophy + +A playlist is not just a ranking problem. Human listeners notice repetition, sudden changes, excessive volume/energy, intrusive vocals, familiarity, silence, and the social function of a place. Simple DjIA therefore uses explicit criteria rather than a hidden recommendation model. + +Each track can carry human-readable metadata: + +- tempo/BPM +- energy +- valence (pleasant/positive affect proxy) +- acousticness +- danceability +- vocal presence +- familiarity +- tags such as `piano`, `instrumental`, `house`, `ambient`, `bossa` + +Each behavior profile describes a desired musical environment and the selector explains why tracks fit it. + +## Included behaviors + +- `restaurant_piano` — discreet piano/acoustic background for conversation +- `mega_party_dj` — high-energy dance-oriented sequencing +- `calm_connection` — gentle, low-overload listening inspired by human connection +- `focus_background` — restrained instrumental background with low vocal presence + +Profiles live in `src/simple_djia/profiles.py`, so interested users can copy, inspect, modify, or create their own. + +## Open-platform design + +The Python core uses only the standard library. Audio playback is delegated to established open tools, detected in this order: + +1. `mpv` +2. `cvlc` (VLC) +3. `ffplay` (FFmpeg) + +This avoids binding the application to proprietary codecs, stores, streaming APIs, DRM, or closed operating-system frameworks. OGG, FLAC, WAV and other formats supported by the installed player are recommended. Users are responsible for the rights to music they add. + +## Requirements + +- Python 3.10+ +- Linux recommended +- Tk/Tkinter for the desktop interface +- optional playback backend: MPV, VLC, or FFmpeg + +Debian/Ubuntu example: + +```bash +sudo apt install python3 python3-tk mpv +``` + +Fedora example: + +```bash +sudo dnf install python3 python3-tkinter mpv +``` + +Arch Linux example: + +```bash +sudo pacman -S python tk mpv +``` + +## Install + +From the `SimpleDjIA` directory: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +pip install -e . +``` + +No runtime Python dependencies beyond the standard library are required. + +## Create a library + +Copy `examples/library.example.json` and point each entry to audio that you are legally allowed to play. Relative paths are resolved from the JSON file location. + +Example: + +```json +{ + "path": "./music/song.ogg", + "title": "Song", + "artist": "Artist", + "genre": "piano", + "bpm": 82, + "energy": 0.30, + "valence": 0.60, + "acousticness": 0.90, + "danceability": 0.20, + "vocal_presence": 0.00, + "familiarity": 0.50, + "tags": ["piano", "instrumental"] +} +``` + +All normalized values use the range `0.0` to `1.0`. + +## Command line + +Preview a restaurant playlist: + +```bash +simple-djia examples/library.example.json --scene restaurant_piano --limit 12 +``` + +Build and play a party session: + +```bash +simple-djia examples/library.example.json --scene mega_party_dj --limit 25 --play +``` + +## Desktop menu + +```bash +simple-djia-gui +``` + +Choose a JSON library, select the desired behavior, choose playlist length, then generate the ordered playlist. + +## How selection works + +1. Tracks carrying explicitly avoided tags are removed. +2. Vocal-density limits are respected for contexts where speech/conversation matters. +3. BPM is compared with the scene's target and tolerance. +4. Human-readable features are compared with scene targets using explicit weights. +5. Preferred tags add a small bonus. +6. The resulting candidates are re-ordered to reduce abrupt BPM/energy/valence changes. +7. Consecutive tracks by the same artist are avoided when a reasonable alternative exists. + +There is no opaque model deciding what a person is supposed to feel. The scoring rules are ordinary Python and can be inspected or changed. + +## Testing and compatibility + +Run locally: + +```bash +pip install pytest +pytest -q +python -m compileall -q src +simple-djia --help +``` + +The repository includes a GitHub Actions matrix for Python 3.10, 3.11, 3.12 and 3.13 on Ubuntu Linux. + +## Project layout + +```text +SimpleDjIA/ +├── pyproject.toml +├── README.md +├── LICENSE +├── examples/ +│ └── library.example.json +├── src/simple_djia/ +│ ├── cli.py +│ ├── gui.py +│ ├── library.py +│ ├── models.py +│ ├── player.py +│ ├── profiles.py +│ ├── selector.py +│ └── session.py +└── tests/ + ├── test_library.py + ├── test_models.py + └── test_selector.py +``` + +## Roadmap + +The first release intentionally favors understandable rules over hype. Useful later additions include manual feedback controls (`more calm`, `more dance`, `less vocal`), local audio-feature extraction, MIDI/live-piano generation using the existing Python Music Composer work, crossfades via MPV IPC, room/session history stored locally, and optional local-only machine learning whose recommendations remain explainable and user-overridable. + +## License + +MIT. See `LICENSE`. From 7071b3773eff3905232691e505f448c83f03ded8 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:34:06 -0300 Subject: [PATCH 17/27] docs(SimpleDjIA): add MIT license --- SimpleDjIA/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SimpleDjIA/LICENSE diff --git a/SimpleDjIA/LICENSE b/SimpleDjIA/LICENSE new file mode 100644 index 0000000..5c914dd --- /dev/null +++ b/SimpleDjIA/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sidiney Rodrigues + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From f9b67bb1a3bf71cda6a58ebf81d112464cfab715 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:35:13 -0300 Subject: [PATCH 18/27] ci(SimpleDjIA): make compatibility checks diagnostic and robust --- .github/workflows/simple-djia.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/simple-djia.yml b/.github/workflows/simple-djia.yml index 719235b..8b76e6b 100644 --- a/.github/workflows/simple-djia.yml +++ b/.github/workflows/simple-djia.yml @@ -19,23 +19,31 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] - defaults: - run: - working-directory: SimpleDjIA steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install + - name: Show runtime run: | - python -m pip install --upgrade pip - pip install -e . pytest - - name: Compile + python --version + python -c "import sys; print(sys.platform); print(sys.executable)" + - name: Compile source + working-directory: SimpleDjIA run: python -m compileall -q src - - name: Tests - run: pytest -q + - name: Install test dependency + run: python -m pip install pytest + - name: Run tests without packaging + working-directory: SimpleDjIA + env: + PYTHONPATH: src + run: python -m pytest -q + - name: Install package + working-directory: SimpleDjIA + run: python -m pip install -e . - name: Smoke CLI + working-directory: SimpleDjIA run: simple-djia --help From 057087e8a87beaafa8dd1912d3b047a3014a5d3a Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:37:51 -0300 Subject: [PATCH 19/27] ci: isolate legacy workflow from SimpleDjIA --- .github/workflows/python-app.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 200872c..5342819 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,22 +1,24 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - +# Legacy PythonProjects workflow. SimpleDjIA has its own isolated compatibility matrix. name: Python application on: push: branches: [ "master" ] + paths-ignore: + - 'SimpleDjIA/**' + - '.github/workflows/simple-djia.yml' pull_request: branches: [ "master" ] + paths-ignore: + - 'SimpleDjIA/**' + - '.github/workflows/simple-djia.yml' permissions: contents: read jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 @@ -30,10 +32,7 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | - # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest - run: | - pytest + run: pytest From 56ca74e378da09a6cb0fdaef4d1e0e494a4c6471 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:38:03 -0300 Subject: [PATCH 20/27] ci: keep legacy checks out of SimpleDjIA-only changes --- .github/workflows/python-app.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 5342819..4b3589a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -7,11 +7,13 @@ on: paths-ignore: - 'SimpleDjIA/**' - '.github/workflows/simple-djia.yml' + - '.github/workflows/python-app.yml' pull_request: branches: [ "master" ] paths-ignore: - 'SimpleDjIA/**' - '.github/workflows/simple-djia.yml' + - '.github/workflows/python-app.yml' permissions: contents: read From f84a2cd04f94ae4c3a04f52e805412bbca42c50a Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:38:50 -0300 Subject: [PATCH 21/27] feat(SimpleDjIA): add interactive playback controls --- SimpleDjIA/src/simple_djia/gui.py | 97 +++++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 10 deletions(-) diff --git a/SimpleDjIA/src/simple_djia/gui.py b/SimpleDjIA/src/simple_djia/gui.py index b6f2d40..b4b21ba 100644 --- a/SimpleDjIA/src/simple_djia/gui.py +++ b/SimpleDjIA/src/simple_djia/gui.py @@ -1,11 +1,13 @@ from __future__ import annotations +import subprocess import tkinter as tk from tkinter import filedialog, messagebox, ttk from .library import load_library +from .player import detect_backend, play from .profiles import SCENES, get_scene -from .session import create_session +from .session import Session, create_session class App(ttk.Frame): @@ -15,9 +17,13 @@ def __init__(self, root: tk.Tk) -> None: self.root.title("Simple DjIA") self.grid(sticky="nsew") self.columnconfigure(1, weight=1) + self.rowconfigure(7, weight=1) self.library = tk.StringVar() self.scene = tk.StringVar(value="restaurant_piano") self.limit = tk.IntVar(value=12) + self.status = tk.StringVar(value="Escolha uma biblioteca e um comportamento.") + self.session: Session | None = None + self.process: subprocess.Popen | None = None self._build() def _build(self) -> None: @@ -31,9 +37,25 @@ def _build(self) -> None: ttk.Label(self, text="Faixas").grid(row=4, column=0, sticky="w") ttk.Spinbox(self, from_=1, to=100, textvariable=self.limit).grid(row=4, column=1, columnspan=2, sticky="ew", padx=(8, 0)) ttk.Button(self, text="Criar playlist humana", command=self._generate).grid(row=5, column=0, columnspan=3, sticky="ew", pady=12) - self.output = tk.Text(self, height=20, width=80, wrap="word") - self.output.grid(row=6, column=0, columnspan=3, sticky="nsew") - self.rowconfigure(6, weight=1) + + controls = ttk.Frame(self) + controls.grid(row=6, column=0, columnspan=3, sticky="ew", pady=(0, 8)) + for column in range(3): + controls.columnconfigure(column, weight=1) + ttk.Button(controls, text="Reproduzir selecionada", command=self._play_selected).grid(row=0, column=0, sticky="ew", padx=(0, 4)) + ttk.Button(controls, text="Próxima", command=self._play_next).grid(row=0, column=1, sticky="ew", padx=4) + ttk.Button(controls, text="Parar", command=self._stop).grid(row=0, column=2, sticky="ew", padx=(4, 0)) + + self.playlist = ttk.Treeview(self, columns=("artist", "title", "bpm", "score", "why"), show="headings", height=18) + for key, label, width in ( + ("artist", "Artista", 140), ("title", "Música", 220), ("bpm", "BPM", 60), + ("score", "Aderência", 75), ("why", "Critérios humanos", 330), + ): + self.playlist.heading(key, text=label) + self.playlist.column(key, width=width, anchor="w") + self.playlist.grid(row=7, column=0, columnspan=3, sticky="nsew") + ttk.Label(self, textvariable=self.status).grid(row=8, column=0, columnspan=3, sticky="w", pady=(8, 0)) + self.root.protocol("WM_DELETE_WINDOW", self._close) def _choose(self) -> None: filename = filedialog.askopenfilename(filetypes=(("JSON", "*.json"), ("Todos", "*.*"))) @@ -43,20 +65,75 @@ def _choose(self) -> None: def _generate(self) -> None: try: scene = get_scene(self.scene.get()) - session = create_session(load_library(self.library.get()), scene, self.limit.get()) + self.session = create_session(load_library(self.library.get()), scene, self.limit.get()) except Exception as exc: messagebox.showerror("Simple DjIA", str(exc)) return - self.output.delete("1.0", tk.END) - self.output.insert(tk.END, f"{scene.name}\n{scene.description}\n\n") - for i, ranked in enumerate(session.playlist, 1): + for item in self.playlist.get_children(): + self.playlist.delete(item) + for index, ranked in enumerate(self.session.playlist): t = ranked.track - self.output.insert(tk.END, f"{i:02d}. {t.artist} — {t.title} | {t.bpm:g} BPM | {ranked.score:.2f}\n") + why = "; ".join(ranked.reasons[:4]) or "equilíbrio geral do perfil" + self.playlist.insert("", "end", iid=str(index), values=(t.artist, t.title, f"{t.bpm:g}", f"{ranked.score:.2f}", why)) + if self.session.playlist: + self.playlist.selection_set("0") + self.status.set(f"{scene.name}: {len(self.session.playlist)} faixas organizadas.") + else: + self.status.set("Nenhuma faixa compatível com o comportamento escolhido.") + + def _selected_index(self) -> int | None: + selected = self.playlist.selection() + if not selected: + return None + return int(selected[0]) + + def _play_index(self, index: int) -> None: + if self.session is None or not 0 <= index < len(self.session.playlist): + return + backend = detect_backend() + if backend is None: + messagebox.showerror("Simple DjIA", "Nenhum player aberto encontrado. Instale mpv, VLC/cvlc ou ffplay.") + return + self._stop() + ranked = self.session.playlist[index] + try: + self.process = play(ranked.track.path, backend) + except (OSError, RuntimeError) as exc: + messagebox.showerror("Simple DjIA", str(exc)) + return + self.session.current_index = index + self.playlist.selection_set(str(index)) + self.playlist.see(str(index)) + self.status.set(f"Tocando: {ranked.track.artist} — {ranked.track.title} via {backend.name}") + + def _play_selected(self) -> None: + index = self._selected_index() + if index is not None: + self._play_index(index) + + def _play_next(self) -> None: + if self.session is None or not self.session.playlist: + return + index = min(self.session.current_index + 1, len(self.session.playlist) - 1) + self._play_index(index) + + def _stop(self) -> None: + if self.process is not None and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + self.process.kill() + self.process = None + + def _close(self) -> None: + self._stop() + self.root.destroy() def main() -> None: root = tk.Tk() - root.minsize(720, 560) + root.minsize(980, 620) App(root) root.mainloop() From 0e819e556e65ab662e2b0a8259ae940aef1c8ad9 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:39:01 -0300 Subject: [PATCH 22/27] test(SimpleDjIA): cover open player backend detection --- SimpleDjIA/tests/test_player.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 SimpleDjIA/tests/test_player.py diff --git a/SimpleDjIA/tests/test_player.py b/SimpleDjIA/tests/test_player.py new file mode 100644 index 0000000..cd02103 --- /dev/null +++ b/SimpleDjIA/tests/test_player.py @@ -0,0 +1,13 @@ +from simple_djia import player + + +def test_detect_backend_prefers_mpv(monkeypatch): + monkeypatch.setattr(player.shutil, "which", lambda exe: "/usr/bin/mpv" if exe == "mpv" else None) + backend = player.detect_backend() + assert backend is not None + assert backend.name == "mpv" + + +def test_detect_backend_returns_none_without_supported_players(monkeypatch): + monkeypatch.setattr(player.shutil, "which", lambda exe: None) + assert player.detect_backend() is None From 6f29bb51838fa1a702abf5cd27f379b203005c0d Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:39:40 -0300 Subject: [PATCH 23/27] docs(SimpleDjIA): record compatibility validation --- SimpleDjIA/COMPATIBILITY.md | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 SimpleDjIA/COMPATIBILITY.md diff --git a/SimpleDjIA/COMPATIBILITY.md b/SimpleDjIA/COMPATIBILITY.md new file mode 100644 index 0000000..1dc60bf --- /dev/null +++ b/SimpleDjIA/COMPATIBILITY.md @@ -0,0 +1,59 @@ +# Simple DjIA compatibility report + +Validation performed during the v0.1 development pass. + +## Local Linux validation + +Environment used by the development runner: + +- Linux +- CPython 3.13.5 +- Tk 8.6 available +- pytest 9.0.2 +- ffplay available as an open playback backend + +Results: + +- `python -m compileall -q src`: PASS +- unit tests: **9 passed** +- CLI smoke test (`python -m simple_djia.cli --help`): PASS +- Tkinter/GUI module import: PASS +- player discovery: PASS; ffplay detected in the validation environment +- editable installation: PASS when build isolation is disabled in the network-isolated development runner (`pip --no-build-isolation -e .`) + +The normal install documented in the README remains `pip install -e .`; it requires access to Python package infrastructure only to satisfy the standard build backend requirement if it is not already installed. + +## Python grammar compatibility + +Every Python source file was parsed using CPython's versioned grammar compatibility mode for: + +- Python 3.10: PASS +- Python 3.11: PASS +- Python 3.12: PASS +- Python 3.13: PASS + +The application intentionally uses only standard-library runtime APIs that are available across this range. + +## GitHub Actions + +A dedicated Ubuntu matrix is committed for Python 3.10, 3.11, 3.12 and 3.13. During this development session, GitHub created all four jobs but reported each as failed before exposing any executed steps or job log blob. Because the jobs did not reach a visible test step, this cannot be treated as a source-code test failure. The workflow remains in the repository so it can execute normally when GitHub-hosted runners are available for the account/repository. + +The legacy root `Python application` workflow is isolated from SimpleDjIA-only changes to avoid collecting unrelated historical experiments in this subproject's validation. + +## Playback compatibility + +Simple DjIA does not embed a proprietary audio engine. It discovers, in order: + +1. MPV (`mpv`) +2. VLC (`cvlc`) +3. FFmpeg player (`ffplay`) + +Actual codec support therefore follows the selected backend. OGG/Vorbis, FLAC and WAV are recommended for an open workflow. + +## Known v0.1 limits + +- audio features are human-supplied metadata; automatic feature extraction is a later enhancement; +- no crossfade/mixing engine yet; +- GUI playback is intentionally simple: selected track, next track and stop; +- no streaming-service or DRM integration by design; +- wellbeing-oriented profiles are contextual curation tools and are not clinical music therapy. From ea1c3733fa8c30026230fdb7d9a575606c2bf771 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:56:02 -0300 Subject: [PATCH 24/27] Stop next action at playlist end --- SimpleDjIA/src/simple_djia/gui.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/SimpleDjIA/src/simple_djia/gui.py b/SimpleDjIA/src/simple_djia/gui.py index b4b21ba..46abe2c 100644 --- a/SimpleDjIA/src/simple_djia/gui.py +++ b/SimpleDjIA/src/simple_djia/gui.py @@ -114,8 +114,10 @@ def _play_selected(self) -> None: def _play_next(self) -> None: if self.session is None or not self.session.playlist: return - index = min(self.session.current_index + 1, len(self.session.playlist) - 1) - self._play_index(index) + if self.session.next() is None: + self.status.set("Fim da playlist.") + return + self._play_index(self.session.current_index) def _stop(self) -> None: if self.process is not None and self.process.poll() is None: From 5301a8fcff46ed7e5285fdeaa13feaa77f1bce65 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:56:03 -0300 Subject: [PATCH 25/27] Test session end behavior --- SimpleDjIA/tests/test_session.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SimpleDjIA/tests/test_session.py diff --git a/SimpleDjIA/tests/test_session.py b/SimpleDjIA/tests/test_session.py new file mode 100644 index 0000000..a04d3c6 --- /dev/null +++ b/SimpleDjIA/tests/test_session.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from simple_djia.models import RankedTrack, Track +from simple_djia.profiles import get_scene +from simple_djia.session import Session + + +def ranked(title: str) -> RankedTrack: + return RankedTrack(Track(Path(f"/{title}.ogg"), title), score=1.0) + + +def test_next_advances_until_end_without_repeating_last_track(): + session = Session( + scene=get_scene("restaurant_piano"), + playlist=[ranked("one"), ranked("two")], + ) + + assert session.next() == session.playlist[1] + assert session.current_index == 1 + assert session.next() is None + assert session.current_index == 1 From 8acb812dca5783a6d260a145724122a6828c935e Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:56:04 -0300 Subject: [PATCH 26/27] Fix playlist limit validation --- SimpleDjIA/src/simple_djia/selector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/SimpleDjIA/src/simple_djia/selector.py b/SimpleDjIA/src/simple_djia/selector.py index 783e69f..5ee3b45 100644 --- a/SimpleDjIA/src/simple_djia/selector.py +++ b/SimpleDjIA/src/simple_djia/selector.py @@ -43,6 +43,9 @@ def _transition_cost(a: Track, b: Track) -> float: def build_playlist(tracks: list[Track], scene: SceneProfile, limit: int = 20) -> list[RankedTrack]: """Rank transparently, then sequence with artist diversity and gradual transitions.""" + if limit < 1: + raise ValueError("limit must be at least 1") + pool = [r for r in (score_track(t, scene) for t in tracks) if r.score > -900] pool.sort(key=lambda item: item.score, reverse=True) if not pool: From a0cd7210a20b42b6a7d8dc310b562407c1324c44 Mon Sep 17 00:00:00 2001 From: Sidiney Rodrigues <4397607+sidineyr@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:56:06 -0300 Subject: [PATCH 27/27] Test invalid playlist limits --- SimpleDjIA/tests/test_selector.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/SimpleDjIA/tests/test_selector.py b/SimpleDjIA/tests/test_selector.py index e873bd0..1c2014a 100644 --- a/SimpleDjIA/tests/test_selector.py +++ b/SimpleDjIA/tests/test_selector.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from simple_djia.models import Track from simple_djia.profiles import get_scene from simple_djia.selector import build_playlist, score_track @@ -33,3 +35,11 @@ def test_playlist_avoids_same_artist_when_alternative_exists(): ] playlist = build_playlist(tracks, scene, 3) assert playlist[0].track.artist != playlist[1].track.artist + + +@pytest.mark.parametrize("limit", [0, -1]) +def test_playlist_rejects_non_positive_limit(limit): + scene = get_scene("restaurant_piano") + tracks = [track("one", "A", 82, 0.3, 0.2, 0.9, 0.6, tags=("piano",))] + with pytest.raises(ValueError, match="at least 1"): + build_playlist(tracks, scene, limit)