coder-music-cli 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.
@@ -0,0 +1,179 @@
1
+ """FFplay-based audio player implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import shutil
6
+ import signal
7
+
8
+ from .base import Player, PlayerState, TrackInfo
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class FFplayPlayer(Player):
14
+ """Audio player using ffplay (part of FFmpeg)."""
15
+
16
+ def __init__(self):
17
+ super().__init__()
18
+ self._process: asyncio.subprocess.Process | None = None
19
+ self._monitor_task: asyncio.Task | None = None
20
+ self._paused = False
21
+
22
+ # Verify ffplay is available
23
+ if not shutil.which("ffplay"):
24
+ logger.warning("ffplay not found in PATH. Please install FFmpeg.")
25
+
26
+ async def play(self, track: TrackInfo) -> bool:
27
+ """Start playing a track using ffplay."""
28
+ # Stop any current playback
29
+ await self.stop()
30
+
31
+ self._state = PlayerState.LOADING
32
+ self._current_track = track
33
+
34
+ try:
35
+ # Build ffplay command
36
+ cmd = [
37
+ "ffplay",
38
+ "-nodisp", # No display window
39
+ "-autoexit", # Exit when done (for files)
40
+ "-loglevel",
41
+ "quiet", # Suppress output
42
+ "-volume",
43
+ str(self._volume),
44
+ ]
45
+
46
+ # For streams, add reconnect options
47
+ if track.source_type == "radio":
48
+ cmd.extend(
49
+ [
50
+ "-reconnect",
51
+ "1",
52
+ "-reconnect_streamed",
53
+ "1",
54
+ "-reconnect_delay_max",
55
+ "5",
56
+ ]
57
+ )
58
+
59
+ cmd.append(track.source)
60
+
61
+ logger.info(f"Starting playback: {track.source}")
62
+
63
+ self._process = await asyncio.create_subprocess_exec(
64
+ *cmd,
65
+ stdin=asyncio.subprocess.DEVNULL,
66
+ stdout=asyncio.subprocess.DEVNULL,
67
+ stderr=asyncio.subprocess.DEVNULL,
68
+ )
69
+
70
+ self._state = PlayerState.PLAYING
71
+ self._paused = False
72
+
73
+ # Start monitoring for process end
74
+ self._monitor_task = asyncio.create_task(self._monitor_playback())
75
+
76
+ return True
77
+
78
+ except Exception as e:
79
+ logger.error(f"Failed to start playback: {e}")
80
+ self._state = PlayerState.ERROR
81
+ return False
82
+
83
+ async def _monitor_playback(self) -> None:
84
+ """Monitor the ffplay process and handle completion."""
85
+ if self._process is None:
86
+ return
87
+
88
+ try:
89
+ await self._process.wait()
90
+
91
+ # Only trigger callback if we weren't stopped manually
92
+ if self._state == PlayerState.PLAYING:
93
+ self._state = PlayerState.STOPPED
94
+ self._current_track = None
95
+
96
+ if self._on_track_end:
97
+ self._on_track_end()
98
+
99
+ except asyncio.CancelledError:
100
+ pass
101
+ except Exception as e:
102
+ logger.error(f"Error monitoring playback: {e}")
103
+
104
+ async def stop(self) -> None:
105
+ """Stop playback."""
106
+ if self._monitor_task:
107
+ self._monitor_task.cancel()
108
+ try:
109
+ await self._monitor_task
110
+ except asyncio.CancelledError:
111
+ pass
112
+ self._monitor_task = None
113
+
114
+ if self._process:
115
+ try:
116
+ self._process.terminate()
117
+ try:
118
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
119
+ except asyncio.TimeoutError:
120
+ self._process.kill()
121
+ await self._process.wait()
122
+ except ProcessLookupError:
123
+ pass # Process already ended
124
+ self._process = None
125
+
126
+ self._state = PlayerState.STOPPED
127
+ self._current_track = None
128
+ self._paused = False
129
+
130
+ async def pause(self) -> None:
131
+ """Pause playback by sending SIGSTOP to ffplay."""
132
+ if self._process and self._state == PlayerState.PLAYING:
133
+ try:
134
+ self._process.send_signal(signal.SIGSTOP)
135
+ self._state = PlayerState.PAUSED
136
+ self._paused = True
137
+ except ProcessLookupError:
138
+ pass
139
+
140
+ async def resume(self) -> None:
141
+ """Resume playback by sending SIGCONT to ffplay."""
142
+ if self._process and self._state == PlayerState.PAUSED:
143
+ try:
144
+ self._process.send_signal(signal.SIGCONT)
145
+ self._state = PlayerState.PLAYING
146
+ self._paused = False
147
+ except ProcessLookupError:
148
+ pass
149
+
150
+ async def set_volume(self, volume: int) -> None:
151
+ """Set volume. Note: ffplay doesn't support runtime volume changes.
152
+
153
+ Volume will apply to the next track.
154
+ """
155
+ self._volume = max(0, min(100, volume))
156
+ # ffplay doesn't support dynamic volume changes
157
+ # The volume will be applied when the next track starts
158
+ logger.info(f"Volume set to {self._volume}% (applies to next track)")
159
+
160
+ async def get_position(self) -> float:
161
+ """Get current playback position.
162
+
163
+ Note: ffplay doesn't provide easy position tracking.
164
+ This is a limitation of the ffplay approach.
165
+ """
166
+ # ffplay doesn't expose position information easily
167
+ # For accurate position tracking, we'd need mpv or VLC
168
+ return 0.0
169
+
170
+ def get_status(self) -> dict:
171
+ """Get current player status."""
172
+ status = super().get_status()
173
+ status["backend"] = "ffplay"
174
+ return status
175
+
176
+
177
+ def check_ffplay_available() -> bool:
178
+ """Check if ffplay is available in PATH."""
179
+ return shutil.which("ffplay") is not None
@@ -0,0 +1,6 @@
1
+ """Music source modules for music-cli."""
2
+
3
+ from .local import LocalSource
4
+ from .radio import RadioSource
5
+
6
+ __all__ = ["LocalSource", "RadioSource"]
@@ -0,0 +1,219 @@
1
+ """AI music generation using MusicGen (optional feature)."""
2
+
3
+ import logging
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from ..player.base import TrackInfo
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Flag to track if AI dependencies are available
12
+ _AI_AVAILABLE: bool | None = None
13
+ _musicgen_model = None
14
+
15
+
16
+ def is_ai_available() -> bool:
17
+ """Check if AI music generation dependencies are available."""
18
+ global _AI_AVAILABLE
19
+
20
+ if _AI_AVAILABLE is not None:
21
+ return _AI_AVAILABLE
22
+
23
+ try:
24
+ import torch # noqa: F401
25
+ from audiocraft.models import MusicGen # noqa: F401
26
+
27
+ _AI_AVAILABLE = True
28
+ logger.info("AI music generation is available")
29
+ except ImportError as e:
30
+ _AI_AVAILABLE = False
31
+ logger.info(f"AI music generation not available: {e}")
32
+
33
+ return _AI_AVAILABLE
34
+
35
+
36
+ def _get_model():
37
+ """Lazy-load the MusicGen model."""
38
+ global _musicgen_model
39
+
40
+ if not is_ai_available():
41
+ return None
42
+
43
+ if _musicgen_model is None:
44
+ try:
45
+ from audiocraft.models import MusicGen
46
+
47
+ logger.info("Loading MusicGen model (this may take a moment)...")
48
+ # Use the small model for faster loading and lower memory usage
49
+ _musicgen_model = MusicGen.get_pretrained("facebook/musicgen-small")
50
+ _musicgen_model.set_generation_params(duration=30) # 30 seconds default
51
+ logger.info("MusicGen model loaded successfully")
52
+ except Exception as e:
53
+ logger.error(f"Failed to load MusicGen model: {e}")
54
+ return None
55
+
56
+ return _musicgen_model
57
+
58
+
59
+ class AIGenerator:
60
+ """Generates music using Meta's MusicGen model."""
61
+
62
+ def __init__(self, output_dir: Path | None = None):
63
+ """Initialize AI generator.
64
+
65
+ Args:
66
+ output_dir: Directory to save generated audio files.
67
+ Defaults to a temp directory.
68
+ """
69
+ if output_dir is None:
70
+ output_dir = Path(tempfile.gettempdir()) / "music-cli-ai"
71
+ self.output_dir = output_dir
72
+ self.output_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ @property
75
+ def available(self) -> bool:
76
+ """Check if AI generation is available."""
77
+ return is_ai_available()
78
+
79
+ def generate(
80
+ self,
81
+ prompt: str,
82
+ duration: int = 30,
83
+ filename: str | None = None,
84
+ ) -> TrackInfo | None:
85
+ """Generate music from a text prompt.
86
+
87
+ Args:
88
+ prompt: Text description of the music to generate.
89
+ duration: Duration in seconds (5-300).
90
+ filename: Optional output filename.
91
+
92
+ Returns:
93
+ TrackInfo for the generated audio, or None if generation failed.
94
+ """
95
+ model = _get_model()
96
+ if model is None:
97
+ logger.warning("AI model not available")
98
+ return None
99
+
100
+ try:
101
+ import scipy.io.wavfile
102
+
103
+ # Clamp duration
104
+ duration = max(5, min(300, duration))
105
+ model.set_generation_params(duration=duration)
106
+
107
+ logger.info(f"Generating {duration}s of music: {prompt[:50]}...")
108
+
109
+ # Generate audio
110
+ wav = model.generate([prompt])
111
+
112
+ # Generate filename if not provided
113
+ if filename is None:
114
+ import hashlib
115
+ import time
116
+
117
+ hash_input = f"{prompt}{time.time()}"
118
+ short_hash = hashlib.md5( # noqa: S324
119
+ hash_input.encode(), usedforsecurity=False
120
+ ).hexdigest()[:8]
121
+ filename = f"ai_music_{short_hash}.wav"
122
+
123
+ # Save to file
124
+ output_path = self.output_dir / filename
125
+
126
+ # Get the audio tensor and sample rate
127
+ audio = wav[0].cpu().numpy()
128
+ sample_rate = model.sample_rate
129
+
130
+ # Save as WAV
131
+ scipy.io.wavfile.write(str(output_path), sample_rate, audio.T)
132
+
133
+ logger.info(f"Generated audio saved to: {output_path}")
134
+
135
+ return TrackInfo(
136
+ source=str(output_path),
137
+ source_type="ai",
138
+ title=f"AI: {prompt[:40]}...",
139
+ metadata={
140
+ "prompt": prompt,
141
+ "duration": duration,
142
+ "model": "musicgen-small",
143
+ },
144
+ )
145
+
146
+ except Exception as e:
147
+ logger.error(f"Failed to generate music: {e}")
148
+ return None
149
+
150
+ def generate_for_context(
151
+ self,
152
+ mood_prompt: str | None = None,
153
+ temporal_prompt: str | None = None,
154
+ duration: int = 30,
155
+ ) -> TrackInfo | None:
156
+ """Generate context-appropriate music.
157
+
158
+ Args:
159
+ mood_prompt: Mood-based prompt component.
160
+ temporal_prompt: Time-based prompt component.
161
+ duration: Duration in seconds.
162
+
163
+ Returns:
164
+ TrackInfo for the generated audio.
165
+ """
166
+ prompts = []
167
+
168
+ if mood_prompt:
169
+ prompts.append(mood_prompt)
170
+ if temporal_prompt:
171
+ prompts.append(temporal_prompt)
172
+
173
+ if not prompts:
174
+ prompts.append("ambient background music")
175
+
176
+ full_prompt = ", ".join(prompts)
177
+ return self.generate(full_prompt, duration)
178
+
179
+ def cleanup_old_files(self, max_age_hours: int = 24) -> int:
180
+ """Clean up old generated files.
181
+
182
+ Args:
183
+ max_age_hours: Maximum age of files to keep.
184
+
185
+ Returns:
186
+ Number of files deleted.
187
+ """
188
+ import time
189
+
190
+ deleted = 0
191
+ cutoff = time.time() - (max_age_hours * 3600)
192
+
193
+ for f in self.output_dir.glob("ai_music_*.wav"):
194
+ if f.stat().st_mtime < cutoff:
195
+ try:
196
+ f.unlink()
197
+ deleted += 1
198
+ except OSError:
199
+ pass
200
+
201
+ return deleted
202
+
203
+
204
+ # Provide helpful error message if AI is not available
205
+ def get_ai_install_instructions() -> str:
206
+ """Get instructions for installing AI dependencies."""
207
+ return """
208
+ AI music generation requires additional dependencies.
209
+ Install them with:
210
+
211
+ pip install 'music-cli[ai]'
212
+
213
+ Or install manually:
214
+
215
+ pip install torch transformers audiocraft
216
+
217
+ Note: This requires significant disk space (~5GB) and RAM (~8GB minimum).
218
+ The first generation will download the MusicGen model (~3GB).
219
+ """
@@ -0,0 +1,75 @@
1
+ """Local MP3 file source."""
2
+
3
+ import random
4
+ from pathlib import Path
5
+
6
+ from ..player.base import TrackInfo
7
+
8
+
9
+ class LocalSource:
10
+ """Handles local MP3 file playback."""
11
+
12
+ SUPPORTED_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".ogg", ".opus"}
13
+
14
+ def __init__(self, music_dir: Path | None = None):
15
+ """Initialize with optional music directory."""
16
+ if music_dir is None:
17
+ # Default to ~/Music
18
+ music_dir = Path("~/Music").expanduser()
19
+ self.music_dir = music_dir
20
+
21
+ def get_track(self, path: str) -> TrackInfo | None:
22
+ """Get track info for a specific file path."""
23
+ file_path = Path(path)
24
+
25
+ if not file_path.is_absolute():
26
+ # Try relative to music dir
27
+ file_path = self.music_dir / file_path
28
+
29
+ if not file_path.exists():
30
+ return None
31
+
32
+ if file_path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
33
+ return None
34
+
35
+ return TrackInfo(
36
+ source=str(file_path),
37
+ source_type="local",
38
+ title=file_path.stem,
39
+ metadata={"filename": file_path.name},
40
+ )
41
+
42
+ def scan_directory(self, directory: Path | None = None) -> list[Path]:
43
+ """Scan a directory for music files."""
44
+ if directory is None:
45
+ directory = self.music_dir
46
+
47
+ if not directory.exists():
48
+ return []
49
+
50
+ files: list[Path] = []
51
+ for ext in self.SUPPORTED_EXTENSIONS:
52
+ files.extend(directory.rglob(f"*{ext}"))
53
+
54
+ return sorted(files)
55
+
56
+ def get_random_track(self, directory: Path | None = None) -> TrackInfo | None:
57
+ """Get a random track from the directory."""
58
+ files = self.scan_directory(directory)
59
+ if not files:
60
+ return None
61
+
62
+ chosen = random.choice(files)
63
+ return self.get_track(str(chosen))
64
+
65
+ def list_tracks(self, directory: Path | None = None, limit: int = 50) -> list[TrackInfo]:
66
+ """List tracks in a directory."""
67
+ files = self.scan_directory(directory)
68
+ tracks = []
69
+
70
+ for f in files[:limit]:
71
+ track = self.get_track(str(f))
72
+ if track:
73
+ tracks.append(track)
74
+
75
+ return tracks
@@ -0,0 +1,90 @@
1
+ """Radio streaming source."""
2
+
3
+ import random
4
+
5
+ from ..config import get_config
6
+ from ..player.base import TrackInfo
7
+
8
+
9
+ class RadioSource:
10
+ """Handles radio stream playback."""
11
+
12
+ def __init__(self):
13
+ """Initialize radio source."""
14
+ self.config = get_config()
15
+
16
+ def get_stations(self) -> list[tuple[str, str]]:
17
+ """Get list of (name, url) tuples for all configured stations."""
18
+ return self.config.get_radios()
19
+
20
+ def get_track(self, url: str, name: str | None = None) -> TrackInfo:
21
+ """Create a track info for a radio stream."""
22
+ if name is None:
23
+ # Try to find name in config
24
+ for station_name, station_url in self.get_stations():
25
+ if station_url == url:
26
+ name = station_name
27
+ break
28
+ if name is None:
29
+ name = url
30
+
31
+ return TrackInfo(
32
+ source=url,
33
+ source_type="radio",
34
+ title=name,
35
+ metadata={"stream_url": url},
36
+ )
37
+
38
+ def get_station_by_name(self, name: str) -> TrackInfo | None:
39
+ """Get a station by its name (case-insensitive partial match)."""
40
+ name_lower = name.lower()
41
+
42
+ for station_name, url in self.get_stations():
43
+ if name_lower in station_name.lower():
44
+ return self.get_track(url, station_name)
45
+
46
+ return None
47
+
48
+ def get_station_by_index(self, index: int) -> TrackInfo | None:
49
+ """Get a station by its index (1-based)."""
50
+ stations = self.get_stations()
51
+ if 1 <= index <= len(stations):
52
+ name, url = stations[index - 1]
53
+ return self.get_track(url, name)
54
+ return None
55
+
56
+ def get_random_station(self) -> TrackInfo | None:
57
+ """Get a random station."""
58
+ stations = self.get_stations()
59
+ if not stations:
60
+ return None
61
+
62
+ name, url = random.choice(stations)
63
+ return self.get_track(url, name)
64
+
65
+ def get_mood_station(self, mood: str) -> TrackInfo | None:
66
+ """Get a station for a specific mood."""
67
+ url = self.config.get_mood_radio(mood)
68
+ if url:
69
+ return self.get_track(url, f"{mood.capitalize()} Radio")
70
+ return None
71
+
72
+ def get_time_station(self, time_period: str) -> TrackInfo | None:
73
+ """Get a station for a time period (morning/afternoon/evening/night)."""
74
+ url = self.config.get_time_radio(time_period)
75
+ if url:
76
+ return self.get_track(url, f"{time_period.capitalize()} Radio")
77
+ return None
78
+
79
+ def list_stations(self) -> list[dict]:
80
+ """List all stations with their indices."""
81
+ stations = []
82
+ for i, (name, url) in enumerate(self.get_stations(), 1):
83
+ stations.append(
84
+ {
85
+ "index": i,
86
+ "name": name,
87
+ "url": url,
88
+ }
89
+ )
90
+ return stations