hey-spotify-cli 1.0.0__tar.gz
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.
- hey_spotify_cli-1.0.0/LICENSE +21 -0
- hey_spotify_cli-1.0.0/PKG-INFO +110 -0
- hey_spotify_cli-1.0.0/README.md +76 -0
- hey_spotify_cli-1.0.0/hey_spotify/__init__.py +15 -0
- hey_spotify_cli-1.0.0/hey_spotify/__main__.py +264 -0
- hey_spotify_cli-1.0.0/hey_spotify/py.typed +1 -0
- hey_spotify_cli-1.0.0/hey_spotify/spotify_client.py +171 -0
- hey_spotify_cli-1.0.0/hey_spotify/ui/__init__.py +0 -0
- hey_spotify_cli-1.0.0/hey_spotify/ui/cli.py +126 -0
- hey_spotify_cli-1.0.0/hey_spotify/ui/theme.py +23 -0
- hey_spotify_cli-1.0.0/hey_spotify/utils.py +51 -0
- hey_spotify_cli-1.0.0/hey_spotify/voice_engine.py +183 -0
- hey_spotify_cli-1.0.0/hey_spotify_cli.egg-info/PKG-INFO +110 -0
- hey_spotify_cli-1.0.0/hey_spotify_cli.egg-info/SOURCES.txt +18 -0
- hey_spotify_cli-1.0.0/hey_spotify_cli.egg-info/dependency_links.txt +1 -0
- hey_spotify_cli-1.0.0/hey_spotify_cli.egg-info/entry_points.txt +2 -0
- hey_spotify_cli-1.0.0/hey_spotify_cli.egg-info/requires.txt +5 -0
- hey_spotify_cli-1.0.0/hey_spotify_cli.egg-info/top_level.txt +1 -0
- hey_spotify_cli-1.0.0/pyproject.toml +52 -0
- hey_spotify_cli-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 prayag
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hey-spotify-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A premium, voice-controlled terminal interface for Spotify playback.
|
|
5
|
+
Author-email: Prayag Tushar <prayagtushar@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prayagtushar/hey-spotify-cli
|
|
8
|
+
Project-URL: Documentation, https://prayagtushar.github.io/hey-spotify-cli/
|
|
9
|
+
Project-URL: Repository, https://github.com/prayagtushar/hey-spotify-cli.git
|
|
10
|
+
Project-URL: BugTracker, https://github.com/prayagtushar/hey-spotify-cli/issues
|
|
11
|
+
Keywords: spotify,cli,voice-control,voice-command,hands-free,music
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: spotipy>=2.24.0
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
30
|
+
Requires-Dist: PyAudio>=0.2.14
|
|
31
|
+
Requires-Dist: SpeechRecognition>=3.10.4
|
|
32
|
+
Requires-Dist: rich>=13.7.1
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# hey-spotify 🎤 🎵
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/hey-spotify-cli/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
A premium, voice-controlled terminal interface for Spotify playback. Control your music without leaving your terminal or touching your keyboard.
|
|
41
|
+
|
|
42
|
+
## 📋 Prerequisites
|
|
43
|
+
|
|
44
|
+
- **Spotify Premium**: A Premium subscription is **required** for playback control via the Spotify API.
|
|
45
|
+
- **Python 3.8+**: Ensure you have a modern Python version installed.
|
|
46
|
+
|
|
47
|
+
## 🚀 Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Install
|
|
50
|
+
Install the package directly via pip:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install hey-spotify-cli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
*Note: Depending on your OS, you might need to install system dependencies for `PyAudio` (e.g., `portaudio` on macOS or `python3-pyaudio` on Linux).*
|
|
57
|
+
|
|
58
|
+
### 2. Spotify API Setup
|
|
59
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard).
|
|
60
|
+
2. Create a new app and name it (e.g., `hey-spotify`).
|
|
61
|
+
3. Add `http://127.0.0.1:8888/callback` as a **Redirect URI** in your app settings.
|
|
62
|
+
4. Set up your environment variables (see [Environment Variables](#environment-variables) section).
|
|
63
|
+
|
|
64
|
+
### 3. Run
|
|
65
|
+
Launch the CLI:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
hey-spotify
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
On the first run, it will open your browser for authentication.
|
|
72
|
+
|
|
73
|
+
## 🎙️ Voice Commands
|
|
74
|
+
|
|
75
|
+
Once active, just say **"Hey Spotify"** followed by:
|
|
76
|
+
|
|
77
|
+
- `play <song name>` — Plays a specific track.
|
|
78
|
+
- `pause` / `stop` — Pauses current playback.
|
|
79
|
+
- `resume` / `continue` — Resumes playback.
|
|
80
|
+
- `next` / `skip` — Skips to the next track.
|
|
81
|
+
- `previous` — Goes back to the previous track.
|
|
82
|
+
- `volume <0-100>` — Sets volume to a specific level.
|
|
83
|
+
- `volume up` / `volume down` — Adjusts volume by 10%.
|
|
84
|
+
- `now playing` — Shows information about the current track.
|
|
85
|
+
- `exit` / `quit` — Closes the application.
|
|
86
|
+
|
|
87
|
+
## ⚙️ Configuration
|
|
88
|
+
|
|
89
|
+
### Environment Variables
|
|
90
|
+
You can create a `.env` file in your working directory or set them in your shell:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
SPOTIPY_CLIENT_ID='your_client_id'
|
|
94
|
+
SPOTIPY_CLIENT_SECRET='your_client_secret'
|
|
95
|
+
SPOTIPY_REDIRECT_URI='http://127.0.0.1:8888/callback'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### CLI Options
|
|
99
|
+
- `--list-mics`: List all detected microphone devices.
|
|
100
|
+
- `--mic-index <index>`: Use a non-default microphone.
|
|
101
|
+
- `--no-wake-word`: Disable "Hey Spotify" trigger and listen continuously.
|
|
102
|
+
- `--language <lang>`: Change recognition language (e.g., `en-US`, `es-ES`).
|
|
103
|
+
- `--voice-debug`: See what the engine is hearing in real-time.
|
|
104
|
+
|
|
105
|
+
## 🛠️ Troubleshooting
|
|
106
|
+
- **Microphone not found**: Ensure you've granted terminal permissions for the microphone and use `--list-mics` to find the correct index.
|
|
107
|
+
- **Spotify Authentication**: Ensure your Redirect URI in the Dashboard exactly matches the one in your environment.
|
|
108
|
+
|
|
109
|
+
## 📄 License
|
|
110
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# hey-spotify 🎤 🎵
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/hey-spotify-cli/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
A premium, voice-controlled terminal interface for Spotify playback. Control your music without leaving your terminal or touching your keyboard.
|
|
7
|
+
|
|
8
|
+
## 📋 Prerequisites
|
|
9
|
+
|
|
10
|
+
- **Spotify Premium**: A Premium subscription is **required** for playback control via the Spotify API.
|
|
11
|
+
- **Python 3.8+**: Ensure you have a modern Python version installed.
|
|
12
|
+
|
|
13
|
+
## 🚀 Quick Start
|
|
14
|
+
|
|
15
|
+
### 1. Install
|
|
16
|
+
Install the package directly via pip:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install hey-spotify-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
*Note: Depending on your OS, you might need to install system dependencies for `PyAudio` (e.g., `portaudio` on macOS or `python3-pyaudio` on Linux).*
|
|
23
|
+
|
|
24
|
+
### 2. Spotify API Setup
|
|
25
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard).
|
|
26
|
+
2. Create a new app and name it (e.g., `hey-spotify`).
|
|
27
|
+
3. Add `http://127.0.0.1:8888/callback` as a **Redirect URI** in your app settings.
|
|
28
|
+
4. Set up your environment variables (see [Environment Variables](#environment-variables) section).
|
|
29
|
+
|
|
30
|
+
### 3. Run
|
|
31
|
+
Launch the CLI:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
hey-spotify
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
On the first run, it will open your browser for authentication.
|
|
38
|
+
|
|
39
|
+
## 🎙️ Voice Commands
|
|
40
|
+
|
|
41
|
+
Once active, just say **"Hey Spotify"** followed by:
|
|
42
|
+
|
|
43
|
+
- `play <song name>` — Plays a specific track.
|
|
44
|
+
- `pause` / `stop` — Pauses current playback.
|
|
45
|
+
- `resume` / `continue` — Resumes playback.
|
|
46
|
+
- `next` / `skip` — Skips to the next track.
|
|
47
|
+
- `previous` — Goes back to the previous track.
|
|
48
|
+
- `volume <0-100>` — Sets volume to a specific level.
|
|
49
|
+
- `volume up` / `volume down` — Adjusts volume by 10%.
|
|
50
|
+
- `now playing` — Shows information about the current track.
|
|
51
|
+
- `exit` / `quit` — Closes the application.
|
|
52
|
+
|
|
53
|
+
## ⚙️ Configuration
|
|
54
|
+
|
|
55
|
+
### Environment Variables
|
|
56
|
+
You can create a `.env` file in your working directory or set them in your shell:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
SPOTIPY_CLIENT_ID='your_client_id'
|
|
60
|
+
SPOTIPY_CLIENT_SECRET='your_client_secret'
|
|
61
|
+
SPOTIPY_REDIRECT_URI='http://127.0.0.1:8888/callback'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### CLI Options
|
|
65
|
+
- `--list-mics`: List all detected microphone devices.
|
|
66
|
+
- `--mic-index <index>`: Use a non-default microphone.
|
|
67
|
+
- `--no-wake-word`: Disable "Hey Spotify" trigger and listen continuously.
|
|
68
|
+
- `--language <lang>`: Change recognition language (e.g., `en-US`, `es-ES`).
|
|
69
|
+
- `--voice-debug`: See what the engine is hearing in real-time.
|
|
70
|
+
|
|
71
|
+
## 🛠️ Troubleshooting
|
|
72
|
+
- **Microphone not found**: Ensure you've granted terminal permissions for the microphone and use `--list-mics` to find the correct index.
|
|
73
|
+
- **Spotify Authentication**: Ensure your Redirect URI in the Dashboard exactly matches the one in your environment.
|
|
74
|
+
|
|
75
|
+
## 📄 License
|
|
76
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
__all__ = ["SpotifyClient", "VoiceEngine", "ParsedCommand", "parse_voice_command"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def __getattr__(name: str) -> Any:
|
|
8
|
+
if name == "SpotifyClient":
|
|
9
|
+
return import_module(".spotify_client", __name__).SpotifyClient
|
|
10
|
+
if name == "VoiceEngine":
|
|
11
|
+
return import_module(".voice_engine", __name__).VoiceEngine
|
|
12
|
+
if name in ("ParsedCommand", "parse_voice_command"):
|
|
13
|
+
utils_module = import_module(".utils", __name__)
|
|
14
|
+
return getattr(utils_module, name)
|
|
15
|
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import argparse
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
except ModuleNotFoundError:
|
|
9
|
+
def load_dotenv() -> bool: # type: ignore[override]
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
from .spotify_client import SpotifyClient
|
|
13
|
+
from .utils import parse_voice_command
|
|
14
|
+
from .voice_engine import VoiceEngine
|
|
15
|
+
from .ui.theme import ACCENT, APP_LOGO, APP_SUBTITLE, ERROR, SUCCESS, WARNING
|
|
16
|
+
|
|
17
|
+
RICH_AVAILABLE = True
|
|
18
|
+
try:
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.live import Live
|
|
21
|
+
|
|
22
|
+
from .ui.cli import generate_boot_banner, generate_layout
|
|
23
|
+
except ModuleNotFoundError:
|
|
24
|
+
RICH_AVAILABLE = False
|
|
25
|
+
|
|
26
|
+
def generate_boot_banner() -> str:
|
|
27
|
+
return f"{APP_LOGO}\n{APP_SUBTITLE}\n"
|
|
28
|
+
|
|
29
|
+
def generate_layout(current_song: str = "None", status: str = "Waiting...", last_command: str = "N/A") -> str:
|
|
30
|
+
return (
|
|
31
|
+
f"Now Playing: {current_song}\n"
|
|
32
|
+
f"Status: {status}\n"
|
|
33
|
+
f"Last Command: {last_command}\n"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PlainConsole:
|
|
38
|
+
def clear(self) -> None:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
def print(self, message: Any) -> None:
|
|
42
|
+
print(_strip_markup(str(message)))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoopLive:
|
|
46
|
+
def __enter__(self) -> "NoopLive":
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def update(self, _renderable: Any) -> None:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_markup(message: str) -> str:
|
|
57
|
+
return re.sub(r"\[/?[^\]]+\]", "", message)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
console = Console() if RICH_AVAILABLE else PlainConsole()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_status(style: str, message: str) -> None:
|
|
64
|
+
if RICH_AVAILABLE:
|
|
65
|
+
console.print(f"[{style}]{message}[/]")
|
|
66
|
+
return
|
|
67
|
+
console.print(message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _update_view(live: Any, current_song: str, status: str, last_command: str) -> None:
|
|
71
|
+
live.update(generate_layout(current_song=current_song, status=status, last_command=last_command))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def handle_command(spotify: SpotifyClient, command: str) -> str:
|
|
75
|
+
parsed = parse_voice_command(command)
|
|
76
|
+
|
|
77
|
+
if parsed.action == "play":
|
|
78
|
+
return await asyncio.to_thread(spotify.play_specific_track, str(parsed.value or ""))
|
|
79
|
+
if parsed.action == "pause":
|
|
80
|
+
return await asyncio.to_thread(spotify.toggle_playback, "pause")
|
|
81
|
+
if parsed.action == "resume":
|
|
82
|
+
return await asyncio.to_thread(spotify.toggle_playback, "resume")
|
|
83
|
+
if parsed.action == "next":
|
|
84
|
+
return await asyncio.to_thread(spotify.skip_track)
|
|
85
|
+
if parsed.action == "previous":
|
|
86
|
+
return await asyncio.to_thread(spotify.previous_track)
|
|
87
|
+
if parsed.action == "volume_set":
|
|
88
|
+
if parsed.value is not None:
|
|
89
|
+
return await asyncio.to_thread(spotify.set_volume, int(parsed.value))
|
|
90
|
+
return "Invalid volume value."
|
|
91
|
+
if parsed.action == "volume_delta":
|
|
92
|
+
if parsed.value is not None:
|
|
93
|
+
return await asyncio.to_thread(spotify.adjust_volume, int(parsed.value))
|
|
94
|
+
return "Invalid volume value."
|
|
95
|
+
if parsed.action == "now_playing":
|
|
96
|
+
song = await asyncio.to_thread(spotify.current_song)
|
|
97
|
+
return f"Now playing: {song}"
|
|
98
|
+
if parsed.action == "exit":
|
|
99
|
+
return "Exiting hey-spotify."
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
"I didn't understand the command. Try: "
|
|
103
|
+
"play <song>, pause, resume, next, previous, volume <0-100>."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_microphones() -> int:
|
|
108
|
+
microphones = VoiceEngine.list_microphones()
|
|
109
|
+
if not microphones:
|
|
110
|
+
print_status(
|
|
111
|
+
ERROR,
|
|
112
|
+
"No microphones detected. Check OS microphone permissions and input device settings.",
|
|
113
|
+
)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
print_status(SUCCESS, "Detected microphone devices:")
|
|
117
|
+
for index, microphone in enumerate(microphones):
|
|
118
|
+
console.print(f" [{index}] {microphone}")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def main(
|
|
123
|
+
microphone_index: Optional[int] = None,
|
|
124
|
+
voice_debug: bool = False,
|
|
125
|
+
require_wake_word: bool = True,
|
|
126
|
+
language: Optional[str] = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
load_dotenv()
|
|
129
|
+
|
|
130
|
+
console.clear()
|
|
131
|
+
console.print(generate_boot_banner())
|
|
132
|
+
await asyncio.sleep(0.8)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
listener = VoiceEngine(
|
|
136
|
+
microphone_index=microphone_index,
|
|
137
|
+
debug=voice_debug,
|
|
138
|
+
language=language,
|
|
139
|
+
)
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
print_status(ERROR, f"Microphone initialization failed: {exc}")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
spotify = SpotifyClient()
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
print_status(ERROR, f"Spotify client initialization failed: {exc}")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
current_song = "Nothing playing"
|
|
151
|
+
status = "Waiting for wake word"
|
|
152
|
+
last_command = "N/A"
|
|
153
|
+
|
|
154
|
+
print_status(SUCCESS, "hey-spotify is active. Say 'Hey Spotify' to begin.")
|
|
155
|
+
print_status(ACCENT, f"Using microphone: {listener.selected_mic_name}")
|
|
156
|
+
print_status(ACCENT, f"Voice language: {listener.language}")
|
|
157
|
+
if not require_wake_word:
|
|
158
|
+
print_status(WARNING, "Wake word disabled. Listening directly for commands.")
|
|
159
|
+
|
|
160
|
+
live_context = (
|
|
161
|
+
Live(
|
|
162
|
+
generate_layout(current_song=current_song, status=status, last_command=last_command),
|
|
163
|
+
console=console,
|
|
164
|
+
refresh_per_second=6,
|
|
165
|
+
)
|
|
166
|
+
if RICH_AVAILABLE
|
|
167
|
+
else NoopLive()
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
with live_context as live:
|
|
171
|
+
while True:
|
|
172
|
+
pending_command: Optional[str] = None
|
|
173
|
+
if require_wake_word:
|
|
174
|
+
status = "Listening for wake word"
|
|
175
|
+
_update_view(live, current_song, status, last_command)
|
|
176
|
+
|
|
177
|
+
wake_word_detected, pending_command = await asyncio.to_thread(
|
|
178
|
+
listener.listen_for_wake_event
|
|
179
|
+
)
|
|
180
|
+
if not wake_word_detected:
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
status = "Wake word detected"
|
|
184
|
+
_update_view(live, current_song, status, last_command)
|
|
185
|
+
else:
|
|
186
|
+
status = "Listening for command"
|
|
187
|
+
_update_view(live, current_song, status, last_command)
|
|
188
|
+
|
|
189
|
+
command = pending_command or await asyncio.to_thread(listener.get_command)
|
|
190
|
+
if not command:
|
|
191
|
+
status = "Could not understand command"
|
|
192
|
+
_update_view(live, current_song, status, last_command)
|
|
193
|
+
print_status(WARNING, "Could not understand command.")
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
last_command = command
|
|
197
|
+
status = "Executing command"
|
|
198
|
+
_update_view(live, current_song, status, last_command)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
result = await handle_command(spotify, command)
|
|
202
|
+
if result == "Exiting hey-spotify.":
|
|
203
|
+
print_status(ACCENT, result)
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
current_song = await asyncio.to_thread(spotify.current_song)
|
|
207
|
+
status = result
|
|
208
|
+
_update_view(live, current_song, status, last_command)
|
|
209
|
+
print_status(ACCENT, result)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
status = "Command failed"
|
|
212
|
+
_update_view(live, current_song, status, last_command)
|
|
213
|
+
print_status(ERROR, f"Error: {exc}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cli() -> None:
|
|
217
|
+
parser = argparse.ArgumentParser(description="EchoSpotify voice-controlled Spotify CLI")
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"--list-mics",
|
|
220
|
+
action="store_true",
|
|
221
|
+
help="List detected microphone devices and exit.",
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--mic-index",
|
|
225
|
+
type=int,
|
|
226
|
+
default=None,
|
|
227
|
+
help="Microphone index to use (find indexes with --list-mics).",
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument(
|
|
230
|
+
"--voice-debug",
|
|
231
|
+
action="store_true",
|
|
232
|
+
help="Print recognized wake/command transcripts for debugging.",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"--no-wake-word",
|
|
236
|
+
action="store_true",
|
|
237
|
+
help="Skip wake-word detection and listen directly for commands.",
|
|
238
|
+
)
|
|
239
|
+
parser.add_argument(
|
|
240
|
+
"--language",
|
|
241
|
+
type=str,
|
|
242
|
+
default=None,
|
|
243
|
+
help="Speech recognition language (example: en-US). Overrides VOICE_LANGUAGE.",
|
|
244
|
+
)
|
|
245
|
+
args = parser.parse_args()
|
|
246
|
+
|
|
247
|
+
if args.list_mics:
|
|
248
|
+
raise SystemExit(list_microphones())
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
asyncio.run(
|
|
252
|
+
main(
|
|
253
|
+
microphone_index=args.mic_index,
|
|
254
|
+
voice_debug=args.voice_debug,
|
|
255
|
+
require_wake_word=not args.no_wake_word,
|
|
256
|
+
language=args.language,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
except KeyboardInterrupt:
|
|
260
|
+
print_status(ERROR, "Shutting down... Goodbye.")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
cli()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Typing marker for PEP 561
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import spotipy
|
|
6
|
+
from spotipy.oauth2 import SpotifyOAuth
|
|
7
|
+
except ModuleNotFoundError:
|
|
8
|
+
spotipy = None
|
|
9
|
+
SpotifyOAuth = None
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
except ModuleNotFoundError:
|
|
14
|
+
def load_dotenv() -> bool: # type: ignore[override]
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpotifyClient:
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
if spotipy is None or SpotifyOAuth is None:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"Missing dependency 'spotipy'. Run: pip install -r requirements.txt"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
load_dotenv()
|
|
26
|
+
client_id = os.getenv("SPOTIPY_CLIENT_ID") or os.getenv("SPOTIFY_CLIENT_ID")
|
|
27
|
+
client_secret = os.getenv("SPOTIPY_CLIENT_SECRET") or os.getenv("SPOTIFY_CLIENT_SECRET")
|
|
28
|
+
redirect_uri = os.getenv("SPOTIPY_REDIRECT_URI") or os.getenv("SPOTIFY_REDIRECT_URI")
|
|
29
|
+
|
|
30
|
+
missing_envs = []
|
|
31
|
+
if not client_id:
|
|
32
|
+
missing_envs.append("SPOTIPY_CLIENT_ID (or SPOTIFY_CLIENT_ID)")
|
|
33
|
+
if not client_secret:
|
|
34
|
+
missing_envs.append("SPOTIPY_CLIENT_SECRET (or SPOTIFY_CLIENT_SECRET)")
|
|
35
|
+
if not redirect_uri:
|
|
36
|
+
missing_envs.append("SPOTIPY_REDIRECT_URI (or SPOTIFY_REDIRECT_URI)")
|
|
37
|
+
if missing_envs:
|
|
38
|
+
formatted = ", ".join(missing_envs)
|
|
39
|
+
raise RuntimeError(f"Missing environment variables: {formatted}")
|
|
40
|
+
|
|
41
|
+
scope = (
|
|
42
|
+
"user-modify-playback-state "
|
|
43
|
+
"user-read-playback-state "
|
|
44
|
+
"user-read-currently-playing"
|
|
45
|
+
)
|
|
46
|
+
self.sp = spotipy.Spotify(
|
|
47
|
+
auth_manager=SpotifyOAuth(
|
|
48
|
+
client_id=client_id,
|
|
49
|
+
client_secret=client_secret,
|
|
50
|
+
redirect_uri=redirect_uri,
|
|
51
|
+
scope=scope,
|
|
52
|
+
cache_path=".cache",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _active_device_id(self) -> Optional[str]:
|
|
57
|
+
try:
|
|
58
|
+
devices = self.sp.devices().get("devices", [])
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
if not devices:
|
|
63
|
+
return None
|
|
64
|
+
for device in devices:
|
|
65
|
+
if device.get("is_active"):
|
|
66
|
+
return device.get("id")
|
|
67
|
+
return devices[0].get("id")
|
|
68
|
+
|
|
69
|
+
def _current_playback(self) -> Optional[dict[str, Any]]:
|
|
70
|
+
try:
|
|
71
|
+
return self.sp.current_playback()
|
|
72
|
+
except Exception:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def current_song(self) -> str:
|
|
76
|
+
playback = self._current_playback()
|
|
77
|
+
if not playback or not playback.get("item"):
|
|
78
|
+
return "Nothing playing"
|
|
79
|
+
|
|
80
|
+
item = playback["item"]
|
|
81
|
+
artist_names = ", ".join(artist["name"] for artist in item.get("artists", []))
|
|
82
|
+
return f"{item.get('name', 'Unknown')} - {artist_names or 'Unknown artist'}"
|
|
83
|
+
|
|
84
|
+
def play_specific_track(self, track_name: str) -> str:
|
|
85
|
+
if not track_name:
|
|
86
|
+
return "Tell me what to play, for example: play Numb by Linkin Park."
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
results = self.sp.search(q=track_name, limit=1, type="track")
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
return f"Spotify search failed: {exc}"
|
|
92
|
+
|
|
93
|
+
items = results.get("tracks", {}).get("items", [])
|
|
94
|
+
if not items:
|
|
95
|
+
return f"Could not find '{track_name}'."
|
|
96
|
+
|
|
97
|
+
track = items[0]
|
|
98
|
+
track_uri = track["uri"]
|
|
99
|
+
track_name = track.get("name", "Unknown song")
|
|
100
|
+
artist_name = track.get("artists", [{}])[0].get("name", "Unknown artist")
|
|
101
|
+
|
|
102
|
+
device_id = self._active_device_id()
|
|
103
|
+
if not device_id:
|
|
104
|
+
return "No Spotify device found. Open Spotify on phone/desktop and try again."
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
self.sp.start_playback(device_id=device_id, uris=[track_uri])
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
return f"Could not start playback: {exc}"
|
|
110
|
+
|
|
111
|
+
return f"Playing {track_name} by {artist_name}."
|
|
112
|
+
|
|
113
|
+
def toggle_playback(self, action: str) -> str:
|
|
114
|
+
device_id = self._active_device_id()
|
|
115
|
+
if not device_id:
|
|
116
|
+
return "No Spotify device found. Open Spotify and start a device first."
|
|
117
|
+
|
|
118
|
+
if action == "pause":
|
|
119
|
+
try:
|
|
120
|
+
self.sp.pause_playback(device_id=device_id)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
return f"Could not pause playback: {exc}"
|
|
123
|
+
return "Paused playback."
|
|
124
|
+
if action == "resume":
|
|
125
|
+
try:
|
|
126
|
+
self.sp.start_playback(device_id=device_id)
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
return f"Could not resume playback: {exc}"
|
|
129
|
+
return "Resumed playback."
|
|
130
|
+
return "Unknown playback action."
|
|
131
|
+
|
|
132
|
+
def skip_track(self) -> str:
|
|
133
|
+
device_id = self._active_device_id()
|
|
134
|
+
if not device_id:
|
|
135
|
+
return "No active Spotify device."
|
|
136
|
+
try:
|
|
137
|
+
self.sp.next_track(device_id=device_id)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
return f"Could not skip track: {exc}"
|
|
140
|
+
return "Skipped to next track."
|
|
141
|
+
|
|
142
|
+
def previous_track(self) -> str:
|
|
143
|
+
device_id = self._active_device_id()
|
|
144
|
+
if not device_id:
|
|
145
|
+
return "No active Spotify device."
|
|
146
|
+
try:
|
|
147
|
+
self.sp.previous_track(device_id=device_id)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
return f"Could not go to previous track: {exc}"
|
|
150
|
+
return "Went to previous track."
|
|
151
|
+
|
|
152
|
+
def set_volume(self, volume_percent: int) -> str:
|
|
153
|
+
volume_percent = max(0, min(100, int(volume_percent)))
|
|
154
|
+
device_id = self._active_device_id()
|
|
155
|
+
if not device_id:
|
|
156
|
+
return "No active Spotify device."
|
|
157
|
+
try:
|
|
158
|
+
self.sp.volume(volume_percent, device_id=device_id)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
return f"Could not set volume: {exc}"
|
|
161
|
+
return f"Volume set to {volume_percent}%."
|
|
162
|
+
|
|
163
|
+
def adjust_volume(self, delta: int) -> str:
|
|
164
|
+
playback = self._current_playback()
|
|
165
|
+
if not playback:
|
|
166
|
+
return "Nothing is playing, so I cannot adjust volume."
|
|
167
|
+
current = playback.get("device", {}).get("volume_percent")
|
|
168
|
+
if current is None:
|
|
169
|
+
return "Could not read current volume."
|
|
170
|
+
target = max(0, min(100, current + delta))
|
|
171
|
+
return self.set_volume(target)
|
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from rich import box
|
|
2
|
+
from rich.align import Align
|
|
3
|
+
from rich.console import Group
|
|
4
|
+
from rich.layout import Layout
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from .theme import (
|
|
9
|
+
APP_LOGO,
|
|
10
|
+
APP_SUBTITLE,
|
|
11
|
+
APP_TITLE,
|
|
12
|
+
FOOTER_BORDER,
|
|
13
|
+
HEADER_BORDER,
|
|
14
|
+
HISTORY_BORDER,
|
|
15
|
+
STATUS_BORDER,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _status_style(status: str) -> str:
|
|
20
|
+
lowered = status.lower()
|
|
21
|
+
if "error" in lowered or "failed" in lowered:
|
|
22
|
+
return "bold red3"
|
|
23
|
+
if "listening" in lowered or "wake word" in lowered:
|
|
24
|
+
return "bold yellow3"
|
|
25
|
+
if any(token in lowered for token in ("playing", "paused", "resumed", "volume", "skipped")):
|
|
26
|
+
return "bold spring_green3"
|
|
27
|
+
return "bold bright_white"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_boot_banner() -> Panel:
|
|
31
|
+
logo = Text.from_markup(f"[spring_green3]{APP_LOGO}[/spring_green3]")
|
|
32
|
+
subtitle = Text.from_markup(f"[hot_pink2]{APP_SUBTITLE}[/hot_pink2]")
|
|
33
|
+
hints = Text.from_markup(
|
|
34
|
+
"[bright_cyan]Wake:[/] [white]'hey spotify'[/] "
|
|
35
|
+
"[bright_cyan]Try:[/] [white]play after hours[/], [white]volume 40[/], [white]next[/]"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return Panel(
|
|
39
|
+
Group(
|
|
40
|
+
Align.center(logo),
|
|
41
|
+
Align.center(subtitle),
|
|
42
|
+
Align.center(Text.from_markup("[grey74]Voice-first music terminal[/grey74]")),
|
|
43
|
+
Align.center(hints),
|
|
44
|
+
),
|
|
45
|
+
title="[bold hot_pink2]BOOT SEQUENCE[/bold hot_pink2]",
|
|
46
|
+
subtitle="[bold white]ready for command capture[/bold white]",
|
|
47
|
+
border_style=HEADER_BORDER,
|
|
48
|
+
box=box.DOUBLE,
|
|
49
|
+
padding=(1, 2),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def generate_layout(current_song: str = "None", status: str = "Waiting...", last_command: str = "N/A") -> Layout:
|
|
54
|
+
"""
|
|
55
|
+
Creates a full-screen layout for the hey-spotify CLI.
|
|
56
|
+
"""
|
|
57
|
+
layout = Layout()
|
|
58
|
+
layout.split_column(
|
|
59
|
+
Layout(name="header", size=8),
|
|
60
|
+
Layout(name="body", ratio=1),
|
|
61
|
+
Layout(name="footer", size=3),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
header_group = Group(
|
|
65
|
+
Align.center(Text.from_markup(f"[bold hot_pink2]{APP_TITLE}[/bold hot_pink2]")),
|
|
66
|
+
Align.center(Text.from_markup("[spring_green3]| ||| ||||| ||| |[/spring_green3]")),
|
|
67
|
+
Align.center(Text.from_markup("[grey70]Hands-free Spotify control in your terminal[/grey70]")),
|
|
68
|
+
)
|
|
69
|
+
layout["header"].update(
|
|
70
|
+
Panel(
|
|
71
|
+
header_group,
|
|
72
|
+
border_style=HEADER_BORDER,
|
|
73
|
+
box=box.HEAVY,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
layout["body"].split_row(
|
|
78
|
+
Layout(name="status_panel", ratio=5),
|
|
79
|
+
Layout(name="log_panel", ratio=4),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
playback_text = Text()
|
|
83
|
+
playback_text.append("NOW PLAYING\n", style="bold bright_white")
|
|
84
|
+
playback_text.append(f"{current_song}\n\n", style="spring_green3")
|
|
85
|
+
playback_text.append("SYSTEM STATUS\n", style="bold bright_white")
|
|
86
|
+
playback_text.append(status, style=_status_style(status))
|
|
87
|
+
|
|
88
|
+
layout["status_panel"].update(
|
|
89
|
+
Panel(
|
|
90
|
+
playback_text,
|
|
91
|
+
title="[bold spring_green3]Playback Deck[/bold spring_green3]",
|
|
92
|
+
border_style=STATUS_BORDER,
|
|
93
|
+
box=box.ROUNDED,
|
|
94
|
+
padding=(1, 2),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
history_text = Text()
|
|
99
|
+
history_text.append("LAST HEARD\n", style="bold bright_white")
|
|
100
|
+
history_text.append(f"'{last_command}'\n\n", style="deep_sky_blue2")
|
|
101
|
+
history_text.append("COMMANDS\n", style="bold bright_white")
|
|
102
|
+
history_text.append("play <song>\n", style="white")
|
|
103
|
+
history_text.append("pause | resume\n", style="white")
|
|
104
|
+
history_text.append("next | previous\n", style="white")
|
|
105
|
+
history_text.append("volume up/down | volume 40\n", style="white")
|
|
106
|
+
history_text.append("now playing", style="white")
|
|
107
|
+
|
|
108
|
+
layout["log_panel"].update(
|
|
109
|
+
Panel(
|
|
110
|
+
history_text,
|
|
111
|
+
title="[bold deep_sky_blue2]Voice Log[/bold deep_sky_blue2]",
|
|
112
|
+
border_style=HISTORY_BORDER,
|
|
113
|
+
box=box.ROUNDED,
|
|
114
|
+
padding=(1, 2),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
layout["footer"].update(
|
|
119
|
+
Panel(
|
|
120
|
+
"[bold yellow3]Wake:[/] 'hey spotify' [bold orchid]Exit:[/] Ctrl+C",
|
|
121
|
+
border_style=FOOTER_BORDER,
|
|
122
|
+
box=box.SQUARE,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return layout
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
APP_TITLE = "hey-spotify"
|
|
2
|
+
APP_SUBTITLE = "Voice Control for Spotify"
|
|
3
|
+
|
|
4
|
+
APP_LOGO = r"""
|
|
5
|
+
:'######::'########:::'#######::'########:'####:'########:'##:::'##:
|
|
6
|
+
'##... ##: ##.... ##:'##.... ##:... ##..::. ##:: ##.....::. ##:'##::
|
|
7
|
+
##:::..:: ##:::: ##: ##:::: ##:::: ##::::: ##:: ##::::::::. ####:::
|
|
8
|
+
. ######:: ########:: ##:::: ##:::: ##::::: ##:: ######:::::. ##::::
|
|
9
|
+
:..... ##: ##.....::: ##:::: ##:::: ##::::: ##:: ##...::::::: ##::::
|
|
10
|
+
'##::: ##: ##:::::::: ##:::: ##:::: ##::::: ##:: ##:::::::::: ##::::
|
|
11
|
+
. ######:: ##::::::::. #######::::: ##::::'####: ##:::::::::: ##::::
|
|
12
|
+
:......:::..::::::::::.......::::::..:::::....::..:::::::::::..:::::
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
ACCENT = "bright_cyan"
|
|
16
|
+
SUCCESS = "spring_green3"
|
|
17
|
+
WARNING = "yellow3"
|
|
18
|
+
ERROR = "red3"
|
|
19
|
+
|
|
20
|
+
HEADER_BORDER = "hot_pink2"
|
|
21
|
+
STATUS_BORDER = "spring_green3"
|
|
22
|
+
HISTORY_BORDER = "deep_sky_blue3"
|
|
23
|
+
FOOTER_BORDER = "orchid"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ParsedCommand:
|
|
8
|
+
action: str
|
|
9
|
+
value: Optional[Union[str, int]] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_voice_command(raw_command: str) -> ParsedCommand:
|
|
13
|
+
command = raw_command.strip().lower()
|
|
14
|
+
if not command:
|
|
15
|
+
return ParsedCommand(action="unknown")
|
|
16
|
+
|
|
17
|
+
if command.startswith("play "):
|
|
18
|
+
query = command.replace("play", "", 1).strip()
|
|
19
|
+
return ParsedCommand(action="play", value=query)
|
|
20
|
+
if command == "play":
|
|
21
|
+
return ParsedCommand(action="play", value="")
|
|
22
|
+
|
|
23
|
+
if any(word in command for word in ("exit", "quit", "goodbye")):
|
|
24
|
+
return ParsedCommand(action="exit")
|
|
25
|
+
|
|
26
|
+
if any(word in command for word in ("pause", "stop")):
|
|
27
|
+
return ParsedCommand(action="pause")
|
|
28
|
+
|
|
29
|
+
if any(word in command for word in ("resume", "continue", "unpause")):
|
|
30
|
+
return ParsedCommand(action="resume")
|
|
31
|
+
|
|
32
|
+
if any(word in command for word in ("next", "skip")):
|
|
33
|
+
return ParsedCommand(action="next")
|
|
34
|
+
|
|
35
|
+
if any(word in command for word in ("previous", "go back", "back track")):
|
|
36
|
+
return ParsedCommand(action="previous")
|
|
37
|
+
|
|
38
|
+
if "volume up" in command or "louder" in command:
|
|
39
|
+
return ParsedCommand(action="volume_delta", value=10)
|
|
40
|
+
|
|
41
|
+
if "volume down" in command or "quieter" in command:
|
|
42
|
+
return ParsedCommand(action="volume_delta", value=-10)
|
|
43
|
+
|
|
44
|
+
volume_match = re.search(r"(?:set )?volume(?: to)?\s+(\d{1,3})", command)
|
|
45
|
+
if volume_match:
|
|
46
|
+
return ParsedCommand(action="volume_set", value=int(volume_match.group(1)))
|
|
47
|
+
|
|
48
|
+
if any(word in command for word in ("what is playing", "now playing", "current song")):
|
|
49
|
+
return ParsedCommand(action="now_playing")
|
|
50
|
+
|
|
51
|
+
return ParsedCommand(action="unknown")
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from difflib import SequenceMatcher
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import speech_recognition as sr
|
|
8
|
+
except ModuleNotFoundError:
|
|
9
|
+
sr = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VoiceEngine:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
microphone_index: Optional[int] = None,
|
|
16
|
+
debug: bool = False,
|
|
17
|
+
language: Optional[str] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
if sr is None:
|
|
20
|
+
raise RuntimeError(
|
|
21
|
+
"Missing dependency 'SpeechRecognition'. Run: pip install -r requirements.txt"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
self.debug = debug
|
|
25
|
+
self.language = (language or os.getenv("VOICE_LANGUAGE") or "en-US").strip() or "en-US"
|
|
26
|
+
self.wake_words = (
|
|
27
|
+
"hey spotify",
|
|
28
|
+
"hello spotify",
|
|
29
|
+
"ok spotify",
|
|
30
|
+
"okay spotify",
|
|
31
|
+
"hey spotty",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
mic_names = self.list_microphones()
|
|
35
|
+
if not mic_names:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"No input device detected. Connect a microphone, grant terminal mic access, "
|
|
38
|
+
"then run: python main.py --list-mics"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if microphone_index is None:
|
|
42
|
+
env_index = os.getenv("MIC_DEVICE_INDEX")
|
|
43
|
+
if env_index:
|
|
44
|
+
try:
|
|
45
|
+
microphone_index = int(env_index)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"Invalid MIC_DEVICE_INDEX='{env_index}'. It must be an integer."
|
|
49
|
+
) from exc
|
|
50
|
+
else:
|
|
51
|
+
microphone_index = self._choose_default_mic_index(mic_names)
|
|
52
|
+
|
|
53
|
+
if microphone_index is not None:
|
|
54
|
+
if microphone_index < 0 or microphone_index >= len(mic_names):
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
f"Invalid microphone index {microphone_index}. Run: python main.py --list-mics"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.recognizer = sr.Recognizer()
|
|
60
|
+
self.recognizer.dynamic_energy_threshold = True
|
|
61
|
+
self.recognizer.pause_threshold = 0.8
|
|
62
|
+
self.recognizer.non_speaking_duration = 0.5
|
|
63
|
+
self.recognizer.phrase_threshold = 0.25
|
|
64
|
+
|
|
65
|
+
selected_index = microphone_index
|
|
66
|
+
self.selected_mic_name = (
|
|
67
|
+
mic_names[selected_index] if selected_index is not None else "system default"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
self.microphone = sr.Microphone(device_index=selected_index)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise RuntimeError(f"No usable microphone found: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
with self.microphone as source:
|
|
76
|
+
self.recognizer.adjust_for_ambient_noise(source, duration=1)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def list_microphones() -> list[str]:
|
|
80
|
+
if sr is None:
|
|
81
|
+
return []
|
|
82
|
+
try:
|
|
83
|
+
return sr.Microphone.list_microphone_names()
|
|
84
|
+
except Exception:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _choose_default_mic_index(mic_names: list[str]) -> int:
|
|
89
|
+
ranked_keywords = ("microphone", "mic", "input", "headset", "airpods")
|
|
90
|
+
for index, name in enumerate(mic_names):
|
|
91
|
+
lowered = name.lower()
|
|
92
|
+
if any(keyword in lowered for keyword in ranked_keywords):
|
|
93
|
+
return index
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _normalize(text: str) -> str:
|
|
98
|
+
cleaned = re.sub(r"[^a-z0-9\s]", " ", text.lower())
|
|
99
|
+
return " ".join(cleaned.split())
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _is_spotify_token(token: str) -> bool:
|
|
103
|
+
known_aliases = {"spotify", "spotty", "spotifly", "spotifi", "spotfy"}
|
|
104
|
+
if token in known_aliases:
|
|
105
|
+
return True
|
|
106
|
+
return SequenceMatcher(None, token, "spotify").ratio() >= 0.72
|
|
107
|
+
|
|
108
|
+
def _extract_wake_remainder(self, transcript: str) -> tuple[bool, Optional[str]]:
|
|
109
|
+
normalized = self._normalize(transcript)
|
|
110
|
+
if not normalized:
|
|
111
|
+
return False, None
|
|
112
|
+
|
|
113
|
+
for wake_word in self.wake_words:
|
|
114
|
+
wake_normalized = self._normalize(wake_word)
|
|
115
|
+
if wake_normalized in normalized:
|
|
116
|
+
remainder = normalized.split(wake_normalized, 1)[1].strip()
|
|
117
|
+
return True, remainder or None
|
|
118
|
+
|
|
119
|
+
tokens = normalized.split()
|
|
120
|
+
if not tokens:
|
|
121
|
+
return False, None
|
|
122
|
+
|
|
123
|
+
greeting_tokens = {"hey", "hello", "hi", "ok", "okay"}
|
|
124
|
+
spotify_index: Optional[int] = None
|
|
125
|
+
for index, token in enumerate(tokens):
|
|
126
|
+
if self._is_spotify_token(token):
|
|
127
|
+
spotify_index = index
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
if spotify_index is None:
|
|
131
|
+
return False, None
|
|
132
|
+
|
|
133
|
+
has_greeting = any(token in greeting_tokens for token in tokens[: spotify_index + 1])
|
|
134
|
+
starts_with_spotify = spotify_index == 0
|
|
135
|
+
if not has_greeting and not starts_with_spotify:
|
|
136
|
+
return False, None
|
|
137
|
+
|
|
138
|
+
remainder_tokens = tokens[spotify_index + 1 :]
|
|
139
|
+
remainder = " ".join(remainder_tokens).strip()
|
|
140
|
+
return True, remainder or None
|
|
141
|
+
|
|
142
|
+
def _transcribe_once(
|
|
143
|
+
self,
|
|
144
|
+
timeout: Optional[float] = None,
|
|
145
|
+
phrase_time_limit: float = 4,
|
|
146
|
+
) -> Optional[str]:
|
|
147
|
+
with self.microphone as source:
|
|
148
|
+
try:
|
|
149
|
+
audio = self.recognizer.listen(
|
|
150
|
+
source,
|
|
151
|
+
timeout=timeout,
|
|
152
|
+
phrase_time_limit=phrase_time_limit,
|
|
153
|
+
)
|
|
154
|
+
except sr.WaitTimeoutError:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
text = self.recognizer.recognize_google(audio, language=self.language)
|
|
159
|
+
except sr.UnknownValueError:
|
|
160
|
+
return None
|
|
161
|
+
except sr.RequestError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
return text.lower().strip()
|
|
165
|
+
|
|
166
|
+
def listen_for_wake_event(self) -> tuple[bool, Optional[str]]:
|
|
167
|
+
transcript = self._transcribe_once(timeout=None, phrase_time_limit=4.5)
|
|
168
|
+
if self.debug and transcript:
|
|
169
|
+
print(f"[voice-debug] wake-listen heard: {transcript}")
|
|
170
|
+
if not transcript:
|
|
171
|
+
return False, None
|
|
172
|
+
|
|
173
|
+
return self._extract_wake_remainder(transcript)
|
|
174
|
+
|
|
175
|
+
def listen_for_wake_word(self) -> bool:
|
|
176
|
+
detected, _ = self.listen_for_wake_event()
|
|
177
|
+
return detected
|
|
178
|
+
|
|
179
|
+
def get_command(self) -> Optional[str]:
|
|
180
|
+
transcript = self._transcribe_once(timeout=5, phrase_time_limit=8)
|
|
181
|
+
if self.debug and transcript:
|
|
182
|
+
print(f"[voice-debug] command heard: {transcript}")
|
|
183
|
+
return transcript
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hey-spotify-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A premium, voice-controlled terminal interface for Spotify playback.
|
|
5
|
+
Author-email: Prayag Tushar <prayagtushar@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/prayagtushar/hey-spotify-cli
|
|
8
|
+
Project-URL: Documentation, https://prayagtushar.github.io/hey-spotify-cli/
|
|
9
|
+
Project-URL: Repository, https://github.com/prayagtushar/hey-spotify-cli.git
|
|
10
|
+
Project-URL: BugTracker, https://github.com/prayagtushar/hey-spotify-cli/issues
|
|
11
|
+
Keywords: spotify,cli,voice-control,voice-command,hands-free,music
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Players
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: spotipy>=2.24.0
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
30
|
+
Requires-Dist: PyAudio>=0.2.14
|
|
31
|
+
Requires-Dist: SpeechRecognition>=3.10.4
|
|
32
|
+
Requires-Dist: rich>=13.7.1
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# hey-spotify 🎤 🎵
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/hey-spotify-cli/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
A premium, voice-controlled terminal interface for Spotify playback. Control your music without leaving your terminal or touching your keyboard.
|
|
41
|
+
|
|
42
|
+
## 📋 Prerequisites
|
|
43
|
+
|
|
44
|
+
- **Spotify Premium**: A Premium subscription is **required** for playback control via the Spotify API.
|
|
45
|
+
- **Python 3.8+**: Ensure you have a modern Python version installed.
|
|
46
|
+
|
|
47
|
+
## 🚀 Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Install
|
|
50
|
+
Install the package directly via pip:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install hey-spotify-cli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
*Note: Depending on your OS, you might need to install system dependencies for `PyAudio` (e.g., `portaudio` on macOS or `python3-pyaudio` on Linux).*
|
|
57
|
+
|
|
58
|
+
### 2. Spotify API Setup
|
|
59
|
+
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard).
|
|
60
|
+
2. Create a new app and name it (e.g., `hey-spotify`).
|
|
61
|
+
3. Add `http://127.0.0.1:8888/callback` as a **Redirect URI** in your app settings.
|
|
62
|
+
4. Set up your environment variables (see [Environment Variables](#environment-variables) section).
|
|
63
|
+
|
|
64
|
+
### 3. Run
|
|
65
|
+
Launch the CLI:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
hey-spotify
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
On the first run, it will open your browser for authentication.
|
|
72
|
+
|
|
73
|
+
## 🎙️ Voice Commands
|
|
74
|
+
|
|
75
|
+
Once active, just say **"Hey Spotify"** followed by:
|
|
76
|
+
|
|
77
|
+
- `play <song name>` — Plays a specific track.
|
|
78
|
+
- `pause` / `stop` — Pauses current playback.
|
|
79
|
+
- `resume` / `continue` — Resumes playback.
|
|
80
|
+
- `next` / `skip` — Skips to the next track.
|
|
81
|
+
- `previous` — Goes back to the previous track.
|
|
82
|
+
- `volume <0-100>` — Sets volume to a specific level.
|
|
83
|
+
- `volume up` / `volume down` — Adjusts volume by 10%.
|
|
84
|
+
- `now playing` — Shows information about the current track.
|
|
85
|
+
- `exit` / `quit` — Closes the application.
|
|
86
|
+
|
|
87
|
+
## ⚙️ Configuration
|
|
88
|
+
|
|
89
|
+
### Environment Variables
|
|
90
|
+
You can create a `.env` file in your working directory or set them in your shell:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
SPOTIPY_CLIENT_ID='your_client_id'
|
|
94
|
+
SPOTIPY_CLIENT_SECRET='your_client_secret'
|
|
95
|
+
SPOTIPY_REDIRECT_URI='http://127.0.0.1:8888/callback'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### CLI Options
|
|
99
|
+
- `--list-mics`: List all detected microphone devices.
|
|
100
|
+
- `--mic-index <index>`: Use a non-default microphone.
|
|
101
|
+
- `--no-wake-word`: Disable "Hey Spotify" trigger and listen continuously.
|
|
102
|
+
- `--language <lang>`: Change recognition language (e.g., `en-US`, `es-ES`).
|
|
103
|
+
- `--voice-debug`: See what the engine is hearing in real-time.
|
|
104
|
+
|
|
105
|
+
## 🛠️ Troubleshooting
|
|
106
|
+
- **Microphone not found**: Ensure you've granted terminal permissions for the microphone and use `--list-mics` to find the correct index.
|
|
107
|
+
- **Spotify Authentication**: Ensure your Redirect URI in the Dashboard exactly matches the one in your environment.
|
|
108
|
+
|
|
109
|
+
## 📄 License
|
|
110
|
+
Distributed under the MIT License. See `LICENSE` for more information.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hey_spotify/__init__.py
|
|
5
|
+
hey_spotify/__main__.py
|
|
6
|
+
hey_spotify/py.typed
|
|
7
|
+
hey_spotify/spotify_client.py
|
|
8
|
+
hey_spotify/utils.py
|
|
9
|
+
hey_spotify/voice_engine.py
|
|
10
|
+
hey_spotify/ui/__init__.py
|
|
11
|
+
hey_spotify/ui/cli.py
|
|
12
|
+
hey_spotify/ui/theme.py
|
|
13
|
+
hey_spotify_cli.egg-info/PKG-INFO
|
|
14
|
+
hey_spotify_cli.egg-info/SOURCES.txt
|
|
15
|
+
hey_spotify_cli.egg-info/dependency_links.txt
|
|
16
|
+
hey_spotify_cli.egg-info/entry_points.txt
|
|
17
|
+
hey_spotify_cli.egg-info/requires.txt
|
|
18
|
+
hey_spotify_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hey_spotify
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hey-spotify-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Prayag Tushar", email="prayagtushar@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A premium, voice-controlled terminal interface for Spotify playback."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
keywords = ["spotify", "cli", "voice-control", "voice-command", "hands-free", "music"]
|
|
15
|
+
license = { text = "MIT" }
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Topic :: Multimedia :: Sound/Audio :: Players",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.8",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"spotipy>=2.24.0",
|
|
33
|
+
"python-dotenv>=1.0.1",
|
|
34
|
+
"PyAudio>=0.2.14",
|
|
35
|
+
"SpeechRecognition>=3.10.4",
|
|
36
|
+
"rich>=13.7.1",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/prayagtushar/hey-spotify-cli"
|
|
41
|
+
Documentation = "https://prayagtushar.github.io/hey-spotify-cli/"
|
|
42
|
+
Repository = "https://github.com/prayagtushar/hey-spotify-cli.git"
|
|
43
|
+
BugTracker = "https://github.com/prayagtushar/hey-spotify-cli/issues"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
hey-spotify = "hey_spotify.__main__:cli"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
packages = ["hey_spotify", "hey_spotify.ui"]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
hey_spotify = ["py.typed"]
|