Skip to content

Commit e9577a2

Browse files
author
pnhofmann
authored
Merge pull request sukeesh#947 from hugofpaiva/feature/music_recognition
[New Feature] Music Recognition
2 parents 0957c7d + e714ad4 commit e9577a2

File tree

4 files changed

+299
-2
lines changed

4 files changed

+299
-2
lines changed

installer/optional.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
'suse': 'python3-PyAudio python3-devel',
3939
'debian': 'python3-pyaudio python3-dev'
4040
}},
41-
"description": "Required for voice control",
41+
"description": "Required for voice control and music recognition",
4242
"instruction": """\
4343
Please install python-binding 'pyaudio' manually."
4444
For more details go to the below link:
@@ -81,7 +81,7 @@
8181
FFMPEG = {
8282
"name": "ffmpeg",
8383
"executable": ['ffmpeg'],
84-
"description": "Download music as .mp3 instead .webm",
84+
"description": "Download music as .mp3 instead .webm. Also needed for music recognition",
8585
"instruction": "Please install 'ffmpeg' manually using your local package manager!",
8686
"package_guess": {
8787
"macos": "ffmpeg",

installer/requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
akinator.py
22
archey4==4.6.0.post1; sys_platform != 'darwin'
33
beautifulsoup4
4+
climage
45
colorama
56
distro
67
flake8
@@ -29,6 +30,7 @@ pyttsx3 == 2.71; sys_platform != 'darwin'
2930
pywin32; sys_platform == 'win32'
3031
random-word
3132
requests[security]
33+
shazamio==0.1.0.1
3234
speedtest-cli
3335
sympy
3436
tabulate
+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
from io import BytesIO
2+
import tempfile
3+
import urllib.error
4+
import urllib.request
5+
from plugin import plugin, require, alias
6+
from colorama import Fore
7+
from shazamio import Shazam
8+
import pydub
9+
import asyncio
10+
import climage
11+
12+
requirements = ['ffmpeg']
13+
try:
14+
import pyaudio
15+
import speech_recognition as sr
16+
except ImportError:
17+
requirements.append(
18+
'voice_control_requirements (install portaudio + re-run setup.sh)')
19+
20+
21+
@require(network=True, native=requirements)
22+
@alias("shazam")
23+
@plugin("music recognition")
24+
class MusicRecognition:
25+
"""A tool to recognize music through Shazam API."""
26+
27+
def __init__(self):
28+
self.sound_recorded = tempfile.NamedTemporaryFile()
29+
self.selected_microphone = None
30+
self.shazam = Shazam()
31+
self.recognizer = sr.Recognizer()
32+
self.duration = 15
33+
34+
def __call__(self, jarvis, s):
35+
loop = asyncio.get_event_loop()
36+
loop.run_until_complete(self.music_recognition(jarvis))
37+
38+
async def music_recognition(self, jarvis):
39+
"""Main function.
40+
41+
This function is the main menu, that will run
42+
until the user says otherwise.
43+
"""
44+
45+
jarvis.say('')
46+
jarvis.say('This tool will let you recognize a music')
47+
jarvis.say('To achieve this, a 15 seconds recording of your microphone is ' +
48+
'done and sent to Shazam servers')
49+
50+
while True:
51+
52+
self.available_options(jarvis)
53+
user_input = jarvis.input('Your choice: ')
54+
user_input = user_input.lower()
55+
56+
if user_input == 'q' or user_input == 'quit' or user_input == '3':
57+
jarvis.say("See you next time :D", Fore.CYAN)
58+
break
59+
60+
# To select input device
61+
if user_input == '1':
62+
self.select_microphone(jarvis)
63+
64+
# To recognize a music
65+
elif user_input == '2':
66+
await self.recognize_music(jarvis)
67+
68+
# For an incorrectly entered option
69+
else:
70+
jarvis.incorrect_option()
71+
continue
72+
73+
def available_options(self, jarvis):
74+
"""
75+
Message displayed to prompt the user about actions of
76+
music recognition plugin.
77+
"""
78+
79+
jarvis.say('\nSelect one of the following options:')
80+
jarvis.say('1: Select input microphone to use')
81+
jarvis.say('2: Record and recognize music')
82+
jarvis.say('3: Quit')
83+
84+
def after_recording_options(self, jarvis):
85+
"""
86+
Message displayed to prompt the user about actions
87+
after microphone recording.
88+
"""
89+
90+
jarvis.say('\nSelect one of the following options:')
91+
jarvis.say('1: Play recorded sound')
92+
jarvis.say('2: Use the recorded sound for music recognition')
93+
jarvis.say('3: Record again')
94+
jarvis.say('4: Quit')
95+
96+
def select_microphone(self, jarvis):
97+
"""Select input device from the available ones"""
98+
99+
input_devices = sr.Microphone.list_microphone_names()
100+
101+
if not input_devices:
102+
jarvis.say('There are no input devices available', Fore.RED)
103+
return None
104+
105+
jarvis.say('\nSelect one of following available input devices:')
106+
107+
for i, val in enumerate(input_devices):
108+
jarvis.say(f"{i+1}: {val}")
109+
110+
selected_input = jarvis.input_number(
111+
prompt='Your choice: ',
112+
rtype=int,
113+
rmin=1,
114+
rmax=len(input_devices)
115+
)
116+
117+
self.selected_microphone = selected_input-1
118+
119+
return input_devices[self.selected_microphone]
120+
121+
async def get_shazam_info(self, jarvis):
122+
"""
123+
Get music match from Shazam servers
124+
If available, draw a pixel art of the cover art
125+
"""
126+
127+
out = await self.shazam.recognize_song(self.sound_recorded.name)
128+
129+
if not 'track' in out:
130+
jarvis.say('No match found', Fore.RED)
131+
else:
132+
jarvis.say('Match found:', Fore.GREEN)
133+
if 'images' in out['track'] and 'coverart' in out['track']['images']:
134+
try:
135+
request = urllib.request.urlopen(
136+
out['track']['images']['coverart'])
137+
downloaded_image = BytesIO(request.read())
138+
pixel_art = climage.convert(downloaded_image, width=50)
139+
jarvis.say(pixel_art)
140+
except urllib.error.URLError:
141+
pass
142+
jarvis.say(f"Song Title: {str(out['track']['title'])}", Fore.GREEN)
143+
jarvis.say(
144+
f"Song Artist: {str(out['track']['subtitle'])}", Fore.GREEN)
145+
146+
def play_last_recorded_sound(self, jarvis):
147+
"""Play the last recorded sound"""
148+
149+
jarvis.say('-----Now playing last recorded sound-----', Fore.BLUE)
150+
mp3_file = pydub.AudioSegment.from_file(
151+
self.sound_recorded, format="mp3")
152+
pydub.playback.play(mp3_file)
153+
154+
async def recognize_music(self, jarvis):
155+
"""Function that does the music recognition flow"""
156+
157+
if not self.record_microphone(jarvis):
158+
return None
159+
160+
while True:
161+
162+
self.after_recording_options(jarvis)
163+
user_input = jarvis.input('Your choice: ')
164+
user_input = user_input.lower()
165+
166+
if user_input == 'q' or user_input == 'quit' or user_input == '4':
167+
break
168+
169+
# To select input device
170+
if user_input == '1':
171+
self.play_last_recorded_sound(jarvis)
172+
173+
# To get Shazam music info
174+
elif user_input == '2':
175+
await self.get_shazam_info(jarvis)
176+
177+
# To record a new sound
178+
elif user_input == '3':
179+
self.record_microphone(jarvis)
180+
181+
# For an incorrectly entered option
182+
else:
183+
jarvis.incorrect_option()
184+
continue
185+
186+
def record_microphone(self, jarvis):
187+
"""Record microphone input for 15 seconds"""
188+
189+
# Although not following PEP 8, using is None because it can be 0
190+
if self.selected_microphone is None:
191+
jarvis.say('Microphone not yet selected...', Fore.RED)
192+
if not self.select_microphone(jarvis):
193+
return None
194+
195+
my_mic = sr.Microphone(device_index=self.selected_microphone)
196+
197+
with my_mic as source:
198+
self.sound_recorded.flush()
199+
200+
jarvis.say('-----Now Recording for 15 seconds-----', Fore.BLUE)
201+
202+
audio_recorded = self.recognizer.record(
203+
source=source, duration=self.duration)
204+
205+
wav_file = pydub.AudioSegment.from_file(
206+
file=BytesIO(audio_recorded.get_wav_data()), format="wav")
207+
208+
wav_file.export(self.sound_recorded, format="mp3")
209+
210+
return self.sound_recorded
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import unittest
2+
from unittest.mock import MagicMock, Mock, patch
3+
from tests import PluginTest
4+
from plugins.music_recognition import MusicRecognition
5+
import requests
6+
7+
"""Instructions to run this test.
8+
9+
source env/bin/activate
10+
cd jarviscli/
11+
python -m unittest tests/test_music_recognition.py
12+
"""
13+
14+
15+
class MusicRecognitionTest(PluginTest):
16+
def setUp(self):
17+
self.test = self.load_plugin(MusicRecognition)
18+
19+
def tearDown(self):
20+
PluginTest.tearDown(self)
21+
22+
def mock_list_microphones():
23+
return ['mock_mic']
24+
25+
def mock_microphone(device_index):
26+
return MagicMock()
27+
28+
def mock_record(source, duration):
29+
"""
30+
Method used to mock the method record
31+
of speech_recognition to return a 15s
32+
excerpt of the Addams Family song
33+
"""
34+
35+
song = requests.get(
36+
'https://drive.google.com/uc?export=download&id=1LAM_dxzuGlrCehg4_wIf68Z_erNaMXGs',
37+
stream=True)
38+
if song.status_code == 200:
39+
mock_object = Mock()
40+
mock_object.get_wav_data.return_value = song.content
41+
return mock_object
42+
else:
43+
raise Exception("Could not download test music")
44+
45+
@patch('speech_recognition.Microphone.list_microphone_names', side_effect=mock_list_microphones)
46+
@patch('speech_recognition.Microphone', side_effect=mock_microphone)
47+
@patch('speech_recognition.Recognizer.record', side_effect=mock_record)
48+
def test_music_recognition(self, args, args1, args2):
49+
"""Test workflow to recognize a music."""
50+
51+
# insert data to be retrieved by jarvis.input()
52+
self.queue_input('2') # select 2 on the menu
53+
54+
# microphone will not be detected...
55+
self.queue_input('1') # select the mock_mic
56+
57+
# Addams Family song will now be given to Shazam
58+
self.queue_input('2') # send to recognize
59+
60+
self.queue_input('4') # leave after recording menu
61+
62+
self.queue_input('q') # leave program
63+
64+
# run code
65+
self.test.run('')
66+
67+
# verify that the mock mic was retrieved
68+
self.assertEqual(self.history_say().view(index=9)[0],
69+
'1: mock_mic')
70+
71+
# verify that the right song title was obtained
72+
self.assertEqual(self.history_say().view(index=18)[0],
73+
'Song Title: The Addams Family')
74+
75+
# verify that the right song artist was obtained
76+
self.assertEqual(self.history_say().view(index=19)[0],
77+
'Song Artist: Andrew Gold')
78+
79+
# verify that the client quited the menu
80+
self.assertEqual(self.history_say().last_text(),
81+
'See you next time :D')
82+
83+
84+
if __name__ == '__main__':
85+
unittest.main()

0 commit comments

Comments
 (0)