diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 200872c..4b3589a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,22 +1,26 @@ -# 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' + - '.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 jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 @@ -30,10 +34,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 diff --git a/.github/workflows/simple-djia.yml b/.github/workflows/simple-djia.yml new file mode 100644 index 0000000..8b76e6b --- /dev/null +++ b/.github/workflows/simple-djia.yml @@ -0,0 +1,49 @@ +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: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Show runtime + run: | + 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: 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 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. 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. 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`. 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"] + } +] 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"] 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" 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() diff --git a/SimpleDjIA/src/simple_djia/gui.py b/SimpleDjIA/src/simple_djia/gui.py new file mode 100644 index 0000000..46abe2c --- /dev/null +++ b/SimpleDjIA/src/simple_djia/gui.py @@ -0,0 +1,144 @@ +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 Session, 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.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: + 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) + + 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", "*.*"))) + if filename: + self.library.set(filename) + + def _generate(self) -> None: + try: + scene = get_scene(self.scene.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 + for item in self.playlist.get_children(): + self.playlist.delete(item) + for index, ranked in enumerate(self.session.playlist): + t = ranked.track + 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 + 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: + 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(980, 620) + App(root) + root.mainloop() + + +if __name__ == "__main__": + main() 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 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) 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)]) 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 diff --git a/SimpleDjIA/src/simple_djia/selector.py b/SimpleDjIA/src/simple_djia/selector.py new file mode 100644 index 0000000..5ee3b45 --- /dev/null +++ b/SimpleDjIA/src/simple_djia/selector.py @@ -0,0 +1,69 @@ +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.""" + 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: + 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 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) 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") 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) 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 diff --git a/SimpleDjIA/tests/test_selector.py b/SimpleDjIA/tests/test_selector.py new file mode 100644 index 0000000..1c2014a --- /dev/null +++ b/SimpleDjIA/tests/test_selector.py @@ -0,0 +1,45 @@ +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 + + +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 + + +@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) 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