├── aiy ├── __init__.py ├── voice │ ├── __init__.py │ └── tts.py ├── _drivers │ ├── __init__.py │ ├── _player.py │ ├── _button.py │ ├── _led.py │ └── _recorder.py ├── i18n.py └── audio.py ├── .gitignore ├── requirements.txt ├── sfx ├── b.mp3 ├── c.mp3 ├── d.mp3 ├── e.mp3 ├── f.mp3 ├── g.mp3 ├── h.mp3 ├── i.mp3 ├── j.mp3 ├── k.mp3 ├── l.mp3 └── m.mp3 ├── assets └── button-recorder.jpg ├── button-recorder.service ├── play.py ├── xbindkeysrc ├── readme.md └── record.py /aiy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiy/voice/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiy/_drivers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | deploy.sh 2 | .DS_store -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pynput==1.4 2 | -------------------------------------------------------------------------------- /sfx/b.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/b.mp3 -------------------------------------------------------------------------------- /sfx/c.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/c.mp3 -------------------------------------------------------------------------------- /sfx/d.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/d.mp3 -------------------------------------------------------------------------------- /sfx/e.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/e.mp3 -------------------------------------------------------------------------------- /sfx/f.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/f.mp3 -------------------------------------------------------------------------------- /sfx/g.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/g.mp3 -------------------------------------------------------------------------------- /sfx/h.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/h.mp3 -------------------------------------------------------------------------------- /sfx/i.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/i.mp3 -------------------------------------------------------------------------------- /sfx/j.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/j.mp3 -------------------------------------------------------------------------------- /sfx/k.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/k.mp3 -------------------------------------------------------------------------------- /sfx/l.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/l.mp3 -------------------------------------------------------------------------------- /sfx/m.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/sfx/m.mp3 -------------------------------------------------------------------------------- /assets/button-recorder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orangespaceman/button-recorder/master/assets/button-recorder.jpg -------------------------------------------------------------------------------- /button-recorder.service: -------------------------------------------------------------------------------- 1 | Description=ButtonRecorder 2 | 3 | [Service] 4 | ExecStart=/bin/bash -c 'DISPLAY=":0" /home/pi/AIY-voice-kit-python/env/bin/python3 -u button-recorder/record.py' 5 | WorkingDirectory=/home/pi/AIY-voice-kit-python 6 | Restart=always 7 | User=pi 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /play.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import random 4 | import subprocess 5 | 6 | 7 | # get button 8 | if len(sys.argv) > 1: 9 | button = sys.argv[1] 10 | 11 | # if not set (a.k.a. big red), pick one at random 12 | else: 13 | buttons = ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm'] 14 | button = random.choice(buttons) 15 | 16 | # set sound file path 17 | dir_path = os.path.dirname(os.path.realpath(__file__)) 18 | mp3 = '{}/sfx/{}.mp3'.format(dir_path, button) 19 | wav = '{}/sfx/{}.wav'.format(dir_path, button) 20 | 21 | # play 22 | if os.path.isfile(wav): 23 | subprocess.call(['aplay', wav]) 24 | elif os.path.isfile(mp3): 25 | subprocess.call(['mpg321', mp3]) 26 | -------------------------------------------------------------------------------- /xbindkeysrc: -------------------------------------------------------------------------------- 1 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py" 2 | Control+Shift+Alt+a 3 | 4 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py b" 5 | Control+Shift+Alt+b 6 | 7 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py c" 8 | Control+Shift+Alt+c 9 | 10 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py d" 11 | Control+Shift+Alt+d 12 | 13 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py e" 14 | Control+Shift+Alt+e 15 | 16 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py f" 17 | Control+Shift+Alt+f 18 | 19 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py g" 20 | Control+Shift+Alt+g 21 | 22 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py h" 23 | Control+Shift+Alt+h 24 | 25 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py i" 26 | Control+Shift+Alt+i 27 | 28 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py j" 29 | Control+Shift+Alt+j 30 | 31 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py k" 32 | Control+Shift+Alt+k 33 | 34 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py l" 35 | Control+Shift+Alt+l 36 | 37 | "python3 /home/pi/AIY-voice-kit-python/button-recorder/play.py m" 38 | Control+Shift+Alt+m 39 | -------------------------------------------------------------------------------- /aiy/voice/tts.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import argparse 16 | import contextlib 17 | import os 18 | import subprocess 19 | import uuid 20 | 21 | 22 | @contextlib.contextmanager 23 | def _stdout_symlink(): 24 | name = '/tmp/%s.wav' % uuid.uuid4().hex 25 | os.symlink('/dev/stdout', name) 26 | try: 27 | yield name 28 | finally: 29 | os.unlink(name) 30 | 31 | 32 | def say(text, lang='en-US', volume=60, pitch=130, speed=100, device='default'): 33 | data = "%s" % \ 34 | (volume, pitch, speed, text) 35 | with _stdout_symlink() as stdout: 36 | cmd = 'pico2wave --wave %s --lang %s "%s" | aplay -D %s -' % (stdout, lang, data, device) 37 | subprocess.check_call(cmd, shell=True) 38 | 39 | 40 | def _main(): 41 | parser = argparse.ArgumentParser(description='Text To Speech (pico2wave)') 42 | parser.add_argument('--lang', default='en-US') 43 | parser.add_argument('--volume', type=int, default=60) 44 | parser.add_argument('--pitch', type=int, default=130) 45 | parser.add_argument('--speed', type=int, default=100) 46 | parser.add_argument('--device', default='default') 47 | parser.add_argument('text', help='path to disk image file ') 48 | args = parser.parse_args() 49 | say(args.text, lang=args.lang, volume=args.volume, pitch=args.pitch, speed=args.speed, 50 | device=args.device) 51 | 52 | 53 | if __name__ == '__main__': 54 | _main() 55 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Button recorder 2 | 3 | Run a custom python script on a Google Voice Kit (Raspberry Pi) to play and record silly sounds, using the [Button Basher](https://github.com/orangespaceman/button-basher) 4 | 5 | 6 | ## Usage 7 | 8 | Once this is set up, you can play sound effects by hitting any button on the board 9 | 10 | You can record new sounds by pressing the button on the Voice Kit, and then assign them to a button on the board 11 | 12 | ![Button Recorder](assets/button-recorder.jpg) 13 | 14 | 15 | ## Installation 16 | 17 | * Set up a Google Voice Kit with a Raspberry Pi, following the [online instructions](https://aiyprojects.withgoogle.com/voice) 18 | 19 | * Create a button board (or similar) following [these instructions](https://github.com/orangespaceman/button-basher) 20 | 21 | * Install `xbindkeys` and `mpg321` onto the Pi: 22 | 23 | ``` 24 | sudo apt-get install xbindkeys mpg321 25 | ``` 26 | 27 | * Clone this repo onto the Pi into the directory: 28 | 29 | ``` 30 | /home/pi/AIY-voice-kit-python/ 31 | ``` 32 | 33 | * From this directory, start the virtualenv: 34 | 35 | ``` 36 | source env/bin/activate 37 | ``` 38 | 39 | * Move into the cloned directory 40 | 41 | ``` 42 | cd button-recorder/ 43 | ``` 44 | 45 | * Copy (or link) the xbindkeysrc file to `~/.xbindkeysrc` 46 | 47 | ``` 48 | ln -s ~/AIY-voice-kit-python/button-recorder/xbindkeysrc ~/.xbindkeysrc 49 | ``` 50 | 51 | (Note that you will have to restart the machine for this to take effect) 52 | 53 | * Install the requirements: 54 | 55 | ``` 56 | pip install -r requirements.txt 57 | ``` 58 | 59 | * Ensure the script works by running it manually: 60 | 61 | ``` 62 | DISPLAY=":0" /home/pi/AIY-voice-kit-python/env/bin/python3 -u /home/pi/AIY-voice-kit-python/button-recorder/record.py 63 | ``` 64 | 65 | * Set up the script to start when you power up the Pi: 66 | 67 | ``` 68 | sudo cp button-recorder.service /lib/systemd/system/ 69 | sudo systemctl enable button-recorder.service 70 | ``` 71 | 72 | * To manually start/stop this service, run: 73 | 74 | ``` 75 | sudo service button-recorder start 76 | sudo service button-recorder stop 77 | sudo service button-recorder status 78 | ``` 79 | -------------------------------------------------------------------------------- /aiy/i18n.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Internationalization helpers.""" 16 | 17 | import gettext 18 | 19 | _DEFAULT_LANGUAGE_CODE = 'en-US' 20 | _LOCALE_DOMAIN = 'voice-recognizer' 21 | 22 | _language_code = _DEFAULT_LANGUAGE_CODE 23 | 24 | _locale_dir = None 25 | 26 | 27 | def set_locale_dir(locale_dir): 28 | """Sets the directory that contains the language bundles. 29 | 30 | This is only required if you call set_language_code with gettext_install=True. 31 | """ 32 | global _locale_dir 33 | if not locale_dir: 34 | raise ValueError('locale_dir must be valid') 35 | _locale_dir = locale_dir 36 | 37 | 38 | def set_language_code(code, gettext_install=False): 39 | """Set the BCP-47 language code that the speech systems should use. 40 | 41 | Args: 42 | gettext_install: if True, gettext's _() will be installed in as a builtin. 43 | As this has global effect, it should only be done by applications. 44 | """ 45 | global _language_code 46 | _language_code = code.replace('_', '-') 47 | 48 | if gettext_install: 49 | if not _locale_dir: 50 | raise ValueError('locale_dir is not set. Please call set_locale_dir().') 51 | language_id = code.replace('-', '_') 52 | t = gettext.translation(_LOCALE_DOMAIN, _locale_dir, [language_id], fallback=True) 53 | t.install() 54 | 55 | 56 | def get_language_code(): 57 | """Returns the BCP-47 language code that the speech systems should use. 58 | 59 | We don't use the system locale because the Assistant API only supports 60 | en-US at launch, so that should be used by default in all environments. 61 | """ 62 | return _language_code 63 | -------------------------------------------------------------------------------- /aiy/_drivers/_player.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """A driver for audio playback.""" 16 | 17 | import logging 18 | import subprocess 19 | import wave 20 | 21 | 22 | logger = logging.getLogger('audio') 23 | 24 | 25 | class Player(object): 26 | """Plays short audio clips from a buffer or file.""" 27 | 28 | def __init__(self, output_device='default'): 29 | self._output_device = output_device 30 | 31 | def play_bytes(self, audio_bytes, sample_rate, sample_width=2): 32 | """Play audio from the given bytes-like object. 33 | 34 | Args: 35 | audio_bytes: audio data (mono) 36 | sample_rate: sample rate in Hertz (24 kHz by default) 37 | sample_width: sample width in bytes (eg 2 for 16-bit audio) 38 | """ 39 | cmd = [ 40 | 'aplay', 41 | '-q', 42 | '-t', 'raw', 43 | '-D', self._output_device, 44 | '-c', '1', 45 | '-f', 's%d' % (8 * sample_width), 46 | '-r', str(sample_rate), 47 | ] 48 | 49 | aplay = subprocess.Popen(cmd, stdin=subprocess.PIPE) 50 | aplay.stdin.write(audio_bytes) 51 | aplay.stdin.close() 52 | retcode = aplay.wait() 53 | 54 | if retcode: 55 | logger.error('aplay failed with %d', retcode) 56 | 57 | def play_wav(self, wav_path): 58 | """Play audio from the given WAV file. 59 | 60 | The file should be mono and small enough to load into memory. 61 | Args: 62 | wav_path: path to the wav file 63 | """ 64 | with wave.open(wav_path, 'r') as wav: 65 | if wav.getnchannels() != 1: 66 | raise ValueError(wav_path + ' is not a mono file') 67 | 68 | frames = wav.readframes(wav.getnframes()) 69 | self.play_bytes(frames, wav.getframerate(), wav.getsampwidth()) 70 | -------------------------------------------------------------------------------- /record.py: -------------------------------------------------------------------------------- 1 | import os 2 | from shutil import copyfile 3 | from pynput import keyboard 4 | from aiy._drivers._led import LED 5 | from aiy._drivers._button import Button 6 | import aiy.audio 7 | 8 | 9 | class ButtonRecorder(): 10 | def __init__(self): 11 | 12 | # list of possible buttons to overwrite 13 | self.buttons = ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm'] 14 | 15 | # path for sfx files 16 | self.dir_path = os.path.dirname(os.path.realpath(__file__)) 17 | self.temp_file = '{}/sfx/temp.wav'.format(self.dir_path) 18 | 19 | # init 20 | self.start_recorder() 21 | self.start_button() 22 | self.start_listener() 23 | 24 | def start_recorder(self): 25 | self.recorder = aiy.audio.get_recorder() 26 | self.recorder.start() 27 | 28 | def start_button(self): 29 | # turn light on 30 | self.pi_button = Button(channel = 23) 31 | self.pi_led = LED(channel = 25) 32 | self.pi_led.start() 33 | 34 | def start_listener(self): 35 | while True: 36 | # wait for input 37 | print('Press the button') 38 | self.pi_led.set_state(LED.PULSE_QUICK) 39 | self.pi_button.wait_for_press() 40 | 41 | # record 42 | self.pi_led.set_state(LED.ON) 43 | aiy.audio.say('Record some sound after the beep. Beep!') 44 | aiy.audio.record_to_wave(self.temp_file, 3) 45 | 46 | # playback 47 | self.pi_led.set_state(LED.DECAY) 48 | aiy.audio.say('Here is your audio recording') 49 | aiy.audio.play_wave(self.temp_file) 50 | 51 | # save 52 | aiy.audio.say('Select a button to save to') 53 | self.pi_led.set_state(LED.BEACON_DARK) 54 | with keyboard.Listener(on_release=self.on_release) as listener: 55 | listener.join() 56 | 57 | def on_release(self, key): 58 | # skip modifiers 59 | if key in [keyboard.Key.ctrl, keyboard.Key.shift, keyboard.Key.alt]: 60 | print('ignore modifier key') 61 | return 62 | 63 | # save? 64 | for button in self.buttons: 65 | if str(key) == "'{}'".format(button): 66 | print('save', button) 67 | self.save(button) 68 | return False 69 | 70 | # discard if key not found 71 | print('discard', str(key)) 72 | aiy.audio.say('Recording discarded') 73 | return False 74 | 75 | def save(self, button): 76 | dest = '{}/sfx/{}.wav'.format(self.dir_path, button) 77 | copyfile(self.temp_file, dest) 78 | print('file saved', dest) 79 | aiy.audio.say('Recording saved') 80 | 81 | 82 | button_recorder = ButtonRecorder() 83 | -------------------------------------------------------------------------------- /aiy/_drivers/_button.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Button driver for the VoiceHat.""" 16 | 17 | import time 18 | import RPi.GPIO as GPIO 19 | 20 | 21 | class Button(object): 22 | """Detect edges on the given GPIO channel.""" 23 | 24 | def __init__(self, 25 | channel, 26 | polarity=GPIO.FALLING, 27 | pull_up_down=GPIO.PUD_UP, 28 | debounce_time=0.08): 29 | """A simple GPIO-based button driver. 30 | 31 | This driver supports a simple GPIO-based button. It works by detecting 32 | edges on the given GPIO channel. Debouncing is automatic. 33 | 34 | Args: 35 | channel: the GPIO pin number to use (BCM mode) 36 | polarity: the GPIO polarity to detect; either GPIO.FALLING or 37 | GPIO.RISING. 38 | pull_up_down: whether the port should be pulled up or down; defaults to 39 | GPIO.PUD_UP. 40 | debounce_time: the time used in debouncing the button in seconds. 41 | """ 42 | if polarity not in [GPIO.FALLING, GPIO.RISING]: 43 | raise ValueError( 44 | 'polarity must be one of: GPIO.FALLING or GPIO.RISING') 45 | 46 | self.channel = int(channel) 47 | self.polarity = polarity 48 | self.expected_value = polarity == GPIO.RISING 49 | self.debounce_time = debounce_time 50 | 51 | GPIO.setmode(GPIO.BCM) 52 | GPIO.setup(channel, GPIO.IN, pull_up_down=pull_up_down) 53 | 54 | self.callback = None 55 | 56 | def __del__(self): 57 | GPIO.cleanup(self.channel) 58 | 59 | def wait_for_press(self): 60 | """Wait for the button to be pressed. 61 | 62 | This method blocks until the button is pressed. 63 | """ 64 | GPIO.add_event_detect(self.channel, self.polarity) 65 | while True: 66 | if GPIO.event_detected(self.channel) and self._debounce(): 67 | GPIO.remove_event_detect(self.channel) 68 | return 69 | time.sleep(0.02) 70 | 71 | def on_press(self, callback): 72 | """Call the callback whenever the button is pressed. 73 | 74 | Args: 75 | callback: a function to call whenever the button is pressed. It should 76 | take a single channel number. If the callback is None, the previously 77 | registered callback, if any, is canceled. 78 | 79 | Example: 80 | def MyButtonPressHandler(channel): 81 | print "button pressed: channel = %d" % channel 82 | my_button.on_press(MyButtonPressHandler) 83 | """ 84 | GPIO.remove_event_detect(self.channel) 85 | if callback: 86 | self.callback = callback 87 | GPIO.add_event_detect( 88 | self.channel, self.polarity, callback=self._debounce_and_callback) 89 | 90 | def _debounce_and_callback(self, _): 91 | if self._debounce(): 92 | self.callback() 93 | 94 | def _debounce(self): 95 | """Debounce the GPIO signal. 96 | 97 | Check that the input holds the expected value for the debounce 98 | period, to avoid false trigger on short pulses. 99 | """ 100 | start = time.time() 101 | while time.time() < start + self.debounce_time: 102 | if GPIO.input(self.channel) != self.expected_value: 103 | return False 104 | time.sleep(0.01) 105 | return True 106 | -------------------------------------------------------------------------------- /aiy/audio.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Drivers for audio functionality provided by the VoiceHat.""" 16 | 17 | import numpy as np 18 | import struct 19 | import time 20 | import wave 21 | 22 | from aiy.voice import tts 23 | import aiy._drivers._player 24 | import aiy._drivers._recorder 25 | import aiy.i18n 26 | 27 | AUDIO_SAMPLE_SIZE = 2 # bytes per sample 28 | AUDIO_SAMPLE_RATE_HZ = 16000 29 | 30 | # Global variables. They are lazily initialized. 31 | _voicehat_recorder = None 32 | _voicehat_player = None 33 | _status_ui = None 34 | 35 | class _WaveDump(object): 36 | """A processor that saves recorded audio to a wave file.""" 37 | 38 | def __init__(self, filepath, duration): 39 | self._wave = wave.open(filepath, 'wb') 40 | self._wave.setnchannels(1) 41 | self._wave.setsampwidth(2) 42 | self._wave.setframerate(16000) 43 | self._bytes = 0 44 | self._bytes_limit = int(duration * 16000) * 1 * 2 45 | 46 | def add_data(self, data): 47 | max_bytes = self._bytes_limit - self._bytes 48 | data = data[:max_bytes] 49 | self._bytes += len(data) 50 | if data: 51 | self._wave.writeframes(data) 52 | 53 | def is_done(self): 54 | return self._bytes >= self._bytes_limit 55 | 56 | def __enter__(self): 57 | return self 58 | 59 | def __exit__(self, *args): 60 | self._wave.close() 61 | 62 | 63 | def get_player(): 64 | """Returns a driver to control the VoiceHat speaker. 65 | 66 | The aiy modules automatically use this player. So usually you do not need to 67 | use this. Instead, use 'aiy.audio.play_wave' if you would like to play some 68 | audio. 69 | """ 70 | global _voicehat_player 71 | if not _voicehat_player: 72 | _voicehat_player = aiy._drivers._player.Player() 73 | return _voicehat_player 74 | 75 | 76 | def get_recorder(): 77 | """Returns a driver to control the VoiceHat microphones. 78 | 79 | The aiy modules automatically use this recorder. So usually you do not need to 80 | use this. 81 | """ 82 | global _voicehat_recorder 83 | if not _voicehat_recorder: 84 | _voicehat_recorder = aiy._drivers._recorder.Recorder() 85 | return _voicehat_recorder 86 | 87 | 88 | def record_to_wave(filepath, duration): 89 | """Records an audio for the given duration to a wave file.""" 90 | recorder = get_recorder() 91 | dumper = _WaveDump(filepath, duration) 92 | with dumper: 93 | recorder.add_processor(dumper) 94 | while not dumper.is_done(): 95 | time.sleep(0.1) 96 | recorder.remove_processor(dumper) 97 | 98 | 99 | def play_wave(wave_file): 100 | """Plays the given wave file. 101 | 102 | The wave file has to be mono and small enough to be loaded in memory. 103 | """ 104 | player = get_player() 105 | player.play_wav(wave_file) 106 | 107 | 108 | def play_audio(audio_data, volume=50): 109 | """Plays the given audio data.""" 110 | player = get_player() 111 | 112 | db_range = -60.0 - (-60.0 * (volume / 100.0)) 113 | db_scaler = 10 ** (db_range / 20) 114 | 115 | adjusted_audio_data = np.multiply(np.frombuffer( 116 | audio_data, dtype=np.int16), db_scaler).astype(np.int16).tobytes() 117 | 118 | player.play_bytes(adjusted_audio_data, sample_width=AUDIO_SAMPLE_SIZE, 119 | sample_rate=AUDIO_SAMPLE_RATE_HZ) 120 | 121 | 122 | def say(words, lang=None, volume=60, pitch=130): 123 | """Says the given words in the given language with Google TTS engine. 124 | 125 | If lang is specified, e.g. "en-US", it will be used to say the given words. 126 | Otherwise, the language from aiy.i18n will be used. 127 | volume (optional) volume used to say the given words. 128 | pitch (optional) pitch to say the given words. 129 | Example: aiy.audio.say('This is an example', lang="en-US", volume=75, pitch=135) 130 | Any of the optional variables can be left out. 131 | """ 132 | if not lang: 133 | lang = aiy.i18n.get_language_code() 134 | tts.say(words, lang=lang, volume=volume, pitch=pitch) 135 | -------------------------------------------------------------------------------- /aiy/_drivers/_led.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """LED driver for the VoiceHat.""" 16 | 17 | import itertools 18 | import threading 19 | import time 20 | import RPi.GPIO as GPIO 21 | 22 | 23 | class LED: 24 | """Starts a background thread to show patterns with the LED. 25 | 26 | Simple usage: 27 | my_led = LED(channel = 25) 28 | my_led.start() 29 | my_led.set_state(LED.BEACON) 30 | my_led.stop() 31 | """ 32 | 33 | OFF = 0 34 | ON = 1 35 | BLINK = 2 36 | BLINK_3 = 3 37 | BEACON = 4 38 | BEACON_DARK = 5 39 | DECAY = 6 40 | PULSE_SLOW = 7 41 | PULSE_QUICK = 8 42 | 43 | def __init__(self, channel): 44 | self.animator = threading.Thread(target=self._animate, daemon=True) 45 | self.channel = channel 46 | self.iterator = None 47 | self.running = False 48 | self.state = None 49 | self.sleep = 0 50 | GPIO.setmode(GPIO.BCM) 51 | GPIO.setup(channel, GPIO.OUT) 52 | self.pwm = GPIO.PWM(channel, 100) 53 | self.lock = threading.Lock() 54 | 55 | def __del__(self): 56 | self.stop() 57 | GPIO.cleanup(self.channel) 58 | 59 | def start(self): 60 | """Start the LED driver.""" 61 | with self.lock: # pylint: disable=E1129 62 | if not self.running: 63 | self.running = True 64 | self.pwm.start(0) # off by default 65 | self.animator.start() 66 | 67 | def stop(self): 68 | """Stop the LED driver and sets the LED to off.""" 69 | with self.lock: # pylint: disable=E1129 70 | if self.running: 71 | self.running = False 72 | 73 | try: 74 | self.animator.join() 75 | except RuntimeError: 76 | # Edge case where this is stopped before it starts. 77 | pass 78 | 79 | self.pwm.stop() 80 | 81 | def set_state(self, state): 82 | """Set the LED driver's new state. 83 | 84 | Note the LED driver must be started for this to have any effect. 85 | """ 86 | with self.lock: # pylint: disable=E1129 87 | self.state = state 88 | 89 | def _animate(self): 90 | while True: 91 | state = None 92 | running = False 93 | with self.lock: # pylint: disable=E1129 94 | state = self.state 95 | self.state = None 96 | running = self.running 97 | if not running: 98 | return 99 | if state is not None: 100 | if not self._parse_state(state): 101 | raise ValueError('unsupported state: %d' % state) 102 | if self.iterator: 103 | self.pwm.ChangeDutyCycle(next(self.iterator)) 104 | time.sleep(self.sleep) 105 | else: 106 | # We can also wait for a state change here with a Condition. 107 | time.sleep(1) 108 | 109 | def _parse_state(self, state): 110 | self.iterator = None 111 | self.sleep = 0.0 112 | handled = False 113 | 114 | if state == self.OFF: 115 | self.pwm.ChangeDutyCycle(0) 116 | handled = True 117 | elif state == self.ON: 118 | self.pwm.ChangeDutyCycle(100) 119 | handled = True 120 | elif state == self.BLINK: 121 | self.iterator = itertools.cycle([0, 100]) 122 | self.sleep = 0.5 123 | handled = True 124 | elif state == self.BLINK_3: 125 | self.iterator = itertools.cycle([0, 100] * 3 + [0, 0]) 126 | self.sleep = 0.25 127 | handled = True 128 | elif state == self.BEACON: 129 | self.iterator = itertools.cycle( 130 | itertools.chain([30] * 100, [100] * 8, range(100, 30, -5))) 131 | self.sleep = 0.05 132 | handled = True 133 | elif state == self.BEACON_DARK: 134 | self.iterator = itertools.cycle( 135 | itertools.chain([0] * 100, range(0, 30, 3), range(30, 0, -3))) 136 | self.sleep = 0.05 137 | handled = True 138 | elif state == self.DECAY: 139 | self.iterator = itertools.cycle(range(100, 0, -2)) 140 | self.sleep = 0.05 141 | handled = True 142 | elif state == self.PULSE_SLOW: 143 | self.iterator = itertools.cycle( 144 | itertools.chain(range(0, 100, 2), range(100, 0, -2))) 145 | self.sleep = 0.1 146 | handled = True 147 | elif state == self.PULSE_QUICK: 148 | self.iterator = itertools.cycle( 149 | itertools.chain(range(0, 100, 5), range(100, 0, -5))) 150 | self.sleep = 0.05 151 | handled = True 152 | 153 | return handled 154 | -------------------------------------------------------------------------------- /aiy/_drivers/_recorder.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """A recorder driver capable of recording voice samples from the VoiceHat microphones.""" 16 | 17 | import logging 18 | import os 19 | import subprocess 20 | import threading 21 | 22 | 23 | logger = logging.getLogger('recorder') 24 | 25 | 26 | class Recorder(threading.Thread): 27 | """A driver to record audio from the VoiceHat microphones. 28 | 29 | Stream audio from microphone in a background thread and run processing 30 | callbacks. It reads audio in a configurable format from the microphone, 31 | then converts it to a known format before passing it to the processors. 32 | 33 | This driver accumulates input (audio samples) in a local buffer. Once the 34 | buffer contains more than CHUNK_S seconds, it passes the chunk to all 35 | processors. An audio processor defines a 'add_data' method that receives 36 | the chunk of audio samples to process. 37 | """ 38 | 39 | CHUNK_S = 0.1 40 | 41 | def __init__(self, input_device='default', 42 | channels=1, bytes_per_sample=2, sample_rate_hz=16000): 43 | """Create a Recorder with the given audio format. 44 | 45 | The Recorder will not start until start() is called. start() is called 46 | automatically if the Recorder is used in a `with`-statement. 47 | 48 | - input_device: name of ALSA device (for a list, run `arecord -L`) 49 | - channels: number of channels in audio read from the mic 50 | - bytes_per_sample: sample width in bytes (eg 2 for 16-bit audio) 51 | - sample_rate_hz: sample rate in hertz 52 | """ 53 | 54 | super().__init__(daemon=True) 55 | 56 | self._record_event = threading.Event() 57 | self._processors = [] 58 | 59 | self._chunk_bytes = int(self.CHUNK_S * sample_rate_hz) * channels * bytes_per_sample 60 | 61 | self._cmd = [ 62 | 'arecord', 63 | '-q', 64 | '-t', 'raw', 65 | '-D', input_device, 66 | '-c', str(channels), 67 | '-f', 's%d' % (8 * bytes_per_sample), 68 | '-r', str(sample_rate_hz), 69 | ] 70 | self._arecord = None 71 | self._closed = False 72 | 73 | def add_processor(self, processor): 74 | """Add an audio processor. 75 | 76 | An audio processor is an object that has an 'add_data' method with the 77 | following signature: 78 | class MyProcessor(object): 79 | def __init__(self): 80 | ... 81 | 82 | def add_data(self, data): 83 | # processes the chunk of data here. 84 | 85 | The added processor may be called multiple times with chunks of audio data. 86 | """ 87 | self._record_event.set() 88 | self._processors.append(processor) 89 | 90 | def remove_processor(self, processor): 91 | """Remove an added audio processor.""" 92 | try: 93 | self._processors.remove(processor) 94 | except ValueError: 95 | logger.warn("processor was not found in the list") 96 | self._record_event.clear() 97 | 98 | def run(self): 99 | """Reads data from arecord and passes to processors.""" 100 | 101 | logger.info("started recording") 102 | 103 | # Check for race-condition when __exit__ is called at the same time as 104 | # the process is started by the background thread 105 | if self._closed: 106 | self._arecord.kill() 107 | return 108 | 109 | this_chunk = b'' 110 | while True: 111 | if not self._record_event.is_set() and self._arecord: 112 | self._arecord.kill() 113 | self._arecord = None 114 | self._record_event.wait() 115 | if not self._arecord: 116 | self._arecord = subprocess.Popen(self._cmd, stdout=subprocess.PIPE) 117 | input_data = self._arecord.stdout.read(self._chunk_bytes) 118 | if not input_data: 119 | break 120 | 121 | this_chunk += input_data 122 | if len(this_chunk) >= self._chunk_bytes: 123 | self._handle_chunk(this_chunk[:self._chunk_bytes]) 124 | this_chunk = this_chunk[self._chunk_bytes:] 125 | 126 | if not self._closed: 127 | logger.error('Microphone recorder died unexpectedly, aborting...') 128 | # sys.exit doesn't work from background threads, so use os._exit as 129 | # an emergency measure. 130 | logging.shutdown() 131 | os._exit(1) # pylint: disable=protected-access 132 | 133 | def stop(self): 134 | """Stops the recorder and cleans up all resources.""" 135 | self._closed = True 136 | if self._arecord: 137 | self._arecord.kill() 138 | 139 | def _handle_chunk(self, chunk): 140 | """Send audio chunk to all processors.""" 141 | for p in self._processors: 142 | p.add_data(chunk) 143 | 144 | def __enter__(self): 145 | self.start() 146 | return self 147 | 148 | def __exit__(self, *args): 149 | self.stop() 150 | --------------------------------------------------------------------------------