├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── coqui_server.py ├── core ├── .gitignore ├── __init__.py ├── arithmetic.py ├── audio.py ├── event.py ├── log.py ├── media_index.py ├── player.py └── util.py ├── deepspeech_server.py ├── dexter.py ├── dexter.service ├── dolly_server.py ├── dot.asoundrc.headless ├── dot.asoundrc.ubuntu-desktop ├── example_config ├── gui ├── __init__.py ├── images │ ├── stop.png │ └── stop_pressed.png ├── screens │ └── __init__.py └── widgets │ ├── __init__.py │ ├── background.py │ ├── button.py │ ├── clock.py │ ├── scroller.py │ └── volume.py ├── input ├── .gitignore ├── __init__.py ├── audio.py ├── coqui.py ├── deepspeech.py ├── digitalio.py ├── gpio.py ├── mediakey.py ├── openai_whisper.py ├── pocketsphinx.py ├── remote.py ├── socket.py └── vosk.py ├── make_asoundrc ├── notifier ├── .gitignore ├── __init__.py ├── desktop.py ├── dotstar.py ├── logging.py ├── scroll_hat_mini.py ├── thingm.py ├── tulogic.py └── unicorn_hat.py ├── output ├── .gitignore ├── __init__.py ├── coqui.py ├── desktop.py ├── espeak.py ├── festvox.py ├── io.py └── mycroft.py ├── pi_config ├── requirements ├── run ├── service ├── .gitignore ├── __init__.py ├── bespoke.py ├── chatbot.py ├── chronos.py ├── dev.py ├── fortune.py ├── language.py ├── life.py ├── music.py ├── numeric.py ├── pandora.py ├── purpleair.py ├── randomness.py ├── spotify.py ├── tplink_kasa.py ├── upnp.py ├── volume.py ├── weather.py └── wikiquery.py ├── test ├── .gitignore └── __init__.py ├── test_config ├── ubuntu_config └── whisper_server.py /.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | /.cache 4 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The root package. 3 | """ 4 | 5 | -------------------------------------------------------------------------------- /coqui_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Listen for incoming data and give back parsed results. 4 | 5 | This can be run a machine with a decent amount of oomph and the L{RemoteService} 6 | can talk to it instead of doing the speech-to-text locally. Thias is handy for 7 | when your client machine is just a Raspberry Pi. 8 | 9 | And, if other Home Hub providers can ship audio off from their Home Hub to the 10 | cloud to process, then it seems only fair that we can do something like that 11 | too. 12 | """ 13 | 14 | from coqui import Model 15 | from threading import Thread 16 | 17 | import argparse 18 | import logging 19 | import numpy 20 | import os 21 | import socket 22 | import struct 23 | import time 24 | 25 | # ------------------------------------------------------------------------------ 26 | 27 | def handle(conn): 28 | """ 29 | Create a new thread for the given connection and start it. 30 | """ 31 | thread = Thread(target=lambda: run(conn)) 32 | thread.daemon = True 33 | thread.start() 34 | 35 | 36 | def run(conn): 37 | """ 38 | Handle a new connection, in its own thread. 39 | """ 40 | try: 41 | # Read in the header data 42 | logging.info("Reading header") 43 | header = b'' 44 | while len(header) < (3 * 8): 45 | got = conn.recv((3 * 8) - len(header)) 46 | if len(got) == 0: 47 | time.sleep(0.001) 48 | continue 49 | header += got 50 | 51 | # Unpack to variables 52 | (channels, width, rate) = struct.unpack('!qqq', header) 53 | logging.info("%d channel(s), %d byte(s) wide, %dHz" % 54 | (channels, width, rate)) 55 | 56 | if model.sampleRate() != rate: 57 | raise ValueError("Given sample rate, %d, differs from desired rate, %d" % 58 | (rate, model.sampleRate())) 59 | if width not in (1, 2, 4, 8): 60 | raise ValueError("Unhandled width: %d" % width) 61 | 62 | # Decode as we go 63 | context = model.createStream() 64 | 65 | # Keep pulling in the data until we get an empty chunk 66 | total_size = 0 67 | while True: 68 | # How big is this incoming chunk? 69 | length_bytes = b'' 70 | while len(length_bytes) < (8): 71 | got = conn.recv(8 - len(length_bytes)) 72 | if len(got) == 0: 73 | time.sleep(0.001) 74 | continue 75 | length_bytes += got 76 | (length,) = struct.unpack('!q', length_bytes) 77 | 78 | # End marker? 79 | if length < 0: 80 | break 81 | 82 | # Pull in the chunk 83 | logging.debug("Reading %d bytes of data" % (length,)) 84 | data = b'' 85 | while len(data) < length: 86 | got = conn.recv(length - len(data)) 87 | if len(got) == 0: 88 | time.sleep(0.001) 89 | continue 90 | data += got 91 | 92 | # Feed it in 93 | if width == 1: audio = numpy.frombuffer(data, numpy.int8) 94 | elif width == 2: audio = numpy.frombuffer(data, numpy.int16) 95 | elif width == 4: audio = numpy.frombuffer(data, numpy.int32) 96 | elif width == 8: audio = numpy.frombuffer(data, numpy.int64) 97 | context.feedAudioContent(audio) 98 | total_size += len(data) 99 | 100 | # Finally, decode it 101 | logging.info("Decoding %0.2f seconds of audio", 102 | total_size / rate / width / channels) 103 | words = context.finishStream() 104 | logging.info("Got: '%s'" % (words,)) 105 | 106 | # Send back the length (as a long) and the string 107 | words = words.encode() 108 | conn.sendall(struct.pack('!q', len(words))) 109 | conn.sendall(words) 110 | 111 | except Exception as e: 112 | # Tell the user at least 113 | logging.error("Error handling incoming data: %s" % e) 114 | 115 | finally: 116 | # We're done with this connection now, close in a best-effort fashion 117 | try: 118 | logging.info("Closing connection") 119 | conn.shutdown(socket.SHUT_RDWR) 120 | conn.close() 121 | except: 122 | pass 123 | 124 | 125 | # ------------------------------------------------------------------------------ 126 | 127 | 128 | # Set up the logger 129 | logging.basicConfig( 130 | format='[%(asctime)s %(threadName)s %(filename)s:%(lineno)d %(levelname)s] %(message)s', 131 | level=logging.INFO 132 | ) 133 | 134 | # Parse the command line args 135 | parser = argparse.ArgumentParser(description='Running Coqui inference.') 136 | parser.add_argument('--model', required=True, 137 | help='Path to the .pbmm file') 138 | parser.add_argument('--scorer', required=False, 139 | help='Path to the .scorer file') 140 | parser.add_argument('--port', type=int, default=8008, 141 | help='The port number to listen on') 142 | args = parser.parse_args() 143 | 144 | # Load in the model 145 | logging.info("Loading model from %s" % args.model) 146 | model = Model(args.model) 147 | 148 | # Load any optional scorer 149 | if args.scorer: 150 | logging.info("Loading scorer from %s" % (args.scorer,)) 151 | model.enableExternalScorer(args.scorer) 152 | 153 | # Set up the server socket 154 | logging.info("Opening socket on port %d" % (args.port,)) 155 | sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 156 | sckt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 157 | sckt.bind(('0.0.0.0', args.port)) 158 | sckt.listen(5) 159 | 160 | # Do this forever 161 | while True: 162 | try: 163 | # Get a connection 164 | logging.info("Waiting for a connection") 165 | (conn, addr) = sckt.accept() 166 | logging.info("Got connection from %s" % (addr,)) 167 | handle(conn) 168 | 169 | except Exception as e: 170 | # Tell the user at least 171 | logging.error("Error handling incoming connection: %s" % e) 172 | -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /core/arithmetic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mathematical functions, at a basic level. 3 | """ 4 | 5 | import math 6 | 7 | class _Value: 8 | """ 9 | Something which may be evaluated to yield a value. 10 | """ 11 | def __call__(self): 12 | """ 13 | The ``()`` method, which subclass must implement. 14 | """ 15 | raise NotImplementedError() 16 | 17 | 18 | class _Function(_Value): 19 | """ 20 | A value gained by calling a function on another. 21 | """ 22 | def __init__(self, v): 23 | self._v = v 24 | 25 | 26 | @property 27 | def v(self): 28 | return self._v() 29 | 30 | 31 | @property 32 | def _description(self): 33 | """ 34 | The description, used to stringify the function. 35 | """ 36 | raise NotImplementedError() 37 | 38 | 39 | def __str__(self): 40 | return f'{self._description} {self._v}' 41 | 42 | 43 | class _BiFunction(_Value): 44 | """ 45 | A value gained by calling a function on two others. 46 | """ 47 | def __init__(self, v0, v1): 48 | self._v0 = v0 49 | self._v1 = v1 50 | 51 | 52 | @property 53 | def v0(self): 54 | return self._v0() 55 | 56 | 57 | @property 58 | def v1(self): 59 | return self._v1() 60 | 61 | 62 | @property 63 | def _description(self): 64 | """ 65 | The description, used to stringify the bi-function. 66 | """ 67 | raise NotImplementedError() 68 | 69 | 70 | def __str__(self): 71 | return f'{self._v0} {self._description} {self._v1}' 72 | 73 | 74 | class Constant(_Value): 75 | """ 76 | A constant floating point value. 77 | """ 78 | def __init__(self, constant): 79 | self._constant = float(constant) 80 | 81 | 82 | def __call__(self): 83 | return self._constant 84 | 85 | 86 | def __str__(self): 87 | return ('%0.7f' % self._constant).rstrip('0').rstrip('.') 88 | 89 | 90 | class ConstantE(Constant): 91 | """ 92 | The constant ``e``. 93 | """ 94 | def __init__(self): 95 | super().__init__(math.e) 96 | 97 | 98 | def __str__(self): 99 | return 'e' 100 | 101 | 102 | class ConstantPi(Constant): 103 | """ 104 | The constant ``pi``. 105 | """ 106 | def __init__(self): 107 | super().__init__(math.pi) 108 | 109 | 110 | def __str__(self): 111 | return 'pi' 112 | 113 | 114 | class ConstantTau(Constant): 115 | """ 116 | The constant ``tau``. 117 | """ 118 | def __init__(self): 119 | super().__init__(math.tau) 120 | 121 | 122 | def __str__(self): 123 | return 'tau' 124 | 125 | 126 | class Add(_BiFunction): 127 | """ 128 | A value gained by adding two other values together. 129 | """ 130 | def __call__(self): 131 | return self.v0 + self.v1 132 | 133 | 134 | @property 135 | def _description(self): 136 | return "plus" 137 | 138 | 139 | class Subtract(_BiFunction): 140 | """ 141 | A value gained by subtracting one value from another. 142 | """ 143 | def __call__(self): 144 | return self.v0 - self.v1 145 | 146 | 147 | @property 148 | def _description(self): 149 | return "minus" 150 | 151 | 152 | class Multiply(_BiFunction): 153 | """ 154 | A value gained by multiplying two values together. 155 | """ 156 | def __call__(self): 157 | return self.v0 * self.v1 158 | 159 | 160 | @property 161 | def _description(self): 162 | return "times" 163 | 164 | 165 | class Divide(_BiFunction): 166 | """ 167 | A value gained by dividing one value by another. 168 | """ 169 | def __call__(self): 170 | return self.v0 / self.v1 171 | 172 | 173 | @property 174 | def _description(self): 175 | return "divided by" 176 | 177 | 178 | class Power(_BiFunction): 179 | """ 180 | A value gained by raising one value to the power of another. 181 | """ 182 | def __call__(self): 183 | return math.pow(self.v0, self.v1) 184 | 185 | 186 | @property 187 | def _description(self): 188 | return "to the power of" 189 | 190 | 191 | class Identity(_Function): 192 | """ 193 | A value which is just itself. 194 | """ 195 | def __init__(self, v): 196 | super().__init__(v) 197 | 198 | 199 | @property 200 | def _description(self): 201 | return "the value of" 202 | 203 | 204 | def __call__(self): 205 | return self.v 206 | 207 | 208 | class Negate(_Function): 209 | """ 210 | A value gained by negating another. 211 | """ 212 | def __call__(self): 213 | return -self.v 214 | 215 | 216 | @property 217 | def _description(self): 218 | return "negative" 219 | 220 | 221 | class Square(_Function): 222 | """ 223 | A value gained by taking the square of another. 224 | """ 225 | def __init__(self, v): 226 | super().__init__(v) 227 | 228 | 229 | @property 230 | def _description(self): 231 | return "the square of" 232 | 233 | 234 | def __call__(self): 235 | return math.pow(self.v, 2) 236 | 237 | 238 | class Cube(_Function): 239 | """ 240 | A value gained by taking the cube of another. 241 | """ 242 | def __init__(self, v): 243 | super().__init__(v) 244 | 245 | 246 | @property 247 | def _description(self): 248 | return "the cube of" 249 | 250 | 251 | def __call__(self): 252 | return math.pow(self.v, 3) 253 | 254 | 255 | class SquareRoot(_Function): 256 | """ 257 | A value gained by taking the square root of another. 258 | """ 259 | def __init__(self, v): 260 | super().__init__(v) 261 | 262 | 263 | @property 264 | def _description(self): 265 | return "the square root of" 266 | 267 | 268 | def __call__(self): 269 | return math.pow(self.v, 1/2) 270 | 271 | 272 | class CubeRoot(_Function): 273 | """ 274 | A value gained by taking the cube root of another. 275 | """ 276 | def __init__(self, v): 277 | super().__init__(v) 278 | 279 | 280 | @property 281 | def _description(self): 282 | return "the cube root of" 283 | 284 | 285 | def __call__(self): 286 | return math.pow(self.v, 1/3) 287 | 288 | 289 | class Factorial(_Function): 290 | """ 291 | A value gained by computing the factorial of another. 292 | """ 293 | def __call__(self): 294 | return math.factorial(self.v) 295 | 296 | 297 | @property 298 | def _description(self): 299 | return "factorial of" 300 | 301 | 302 | class Sine(_Function): 303 | """ 304 | A value gained by computing the sine of another. 305 | """ 306 | def __call__(self): 307 | return math.sin(self.v) 308 | 309 | 310 | @property 311 | def _description(self): 312 | return "sine of" 313 | 314 | 315 | class Cosine(_Function): 316 | """ 317 | A value gained by computing the cosine of another. 318 | """ 319 | def __call__(self): 320 | return math.cos(self.v) 321 | 322 | 323 | @property 324 | def _description(self): 325 | return "cosine of" 326 | 327 | 328 | class Tangent(_Function): 329 | """ 330 | A value gained by computing the tangent of another. 331 | """ 332 | def __call__(self): 333 | return math.tan(self.v) 334 | 335 | 336 | @property 337 | def _description(self): 338 | return "tan of" 339 | 340 | 341 | class Log(_Function): 342 | """ 343 | A value gained by computing log10 of another. 344 | """ 345 | def __call__(self): 346 | return math.log10(self.v) 347 | 348 | 349 | @property 350 | def _description(self): 351 | return "log of" 352 | 353 | 354 | class NaturalLog(_Function): 355 | """ 356 | A value gained by computing the natural log of another. 357 | """ 358 | def __call__(self): 359 | return math.log(self.v) 360 | 361 | 362 | @property 363 | def _description(self): 364 | return "natural log of" 365 | 366 | 367 | class Log2(_Function): 368 | """ 369 | A value gained by computing log2 of another. 370 | """ 371 | def __call__(self): 372 | return math.log2(self.v) 373 | 374 | 375 | @property 376 | def _description(self): 377 | return "log 2 of" 378 | 379 | 380 | class DegreesToRadians(_Function): 381 | """ 382 | A value gained by converting a degrees value to a radians one. 383 | """ 384 | def __call__(self): 385 | return math.radians(self.v) 386 | 387 | 388 | @property 389 | def _description(self): 390 | return "the radians value of" 391 | 392 | 393 | class RadiansToDegrees(_Function): 394 | """ 395 | A value gained by converting a radians value to a degrees one. 396 | """ 397 | def __call__(self): 398 | return math.degrees(self.v) 399 | 400 | 401 | @property 402 | def _description(self): 403 | return "the degrees value of" 404 | 405 | 406 | class FahrenheitToCelcius(_Function): 407 | """ 408 | A value gained by converting a fahrenheit value to celcius. 409 | """ 410 | def __call__(self): 411 | return (self.v - 32) * 5 / 9 412 | 413 | 414 | @property 415 | def _description(self): 416 | return "the celcius value of" 417 | 418 | 419 | class CelciusToFahrenheit(_Function): 420 | """ 421 | A value gained by converting a celcius value to fahrenheit. 422 | """ 423 | def __call__(self): 424 | return self.v * 9 / 5 + 32 425 | 426 | 427 | @property 428 | def _description(self): 429 | return "the fahrenheit value of" 430 | -------------------------------------------------------------------------------- /core/audio.py: -------------------------------------------------------------------------------- 1 | """ 2 | Methods for dealing with audio I/O manipulation. 3 | """ 4 | 5 | from dexter.core.log import LOG 6 | 7 | import alsaaudio 8 | 9 | # ------------------------------------------------------------------------------ 10 | 11 | MIN_VOLUME = 0 12 | MAX_VOLUME = 11 13 | 14 | # ------------------------------------------------------------------------------ 15 | 16 | 17 | def set_volume(value): 18 | """ 19 | Set the volume to a value between zero and eleven. 20 | 21 | :type value: float 22 | :param value: 23 | The volume level to set. This should be between `MIN_VOLUME` and 24 | `MAX_VOLUME` inclusive. 25 | """ 26 | volume = float(value) 27 | 28 | if volume < MIN_VOLUME or volume > MAX_VOLUME: 29 | raise ValueError("Volume out of [%d..%d] range: %s" % 30 | (MIN_VOLUME, MAX_VOLUME, value)) 31 | 32 | # Get the ALSA mixer 33 | m = _get_alsa_mixer() 34 | 35 | # Set as a percentage 36 | pct = int((volume / MAX_VOLUME) * 100) 37 | LOG.info("Setting volume to %d%%" % pct) 38 | m.setvolume(pct) 39 | 40 | 41 | def get_volume(): 42 | """ 43 | Get the current volume, as a value between zero and eleven. 44 | 45 | :rtype: float 46 | :return: 47 | The volume level; between 0 and 11 inclusive. 48 | """ 49 | # Get the ALSA mixer 50 | m = _get_alsa_mixer() 51 | 52 | # And give it back, assuming the we care about the highest value 53 | return float(MAX_VOLUME) * min(100, max((0,) + tuple(m.getvolume()))) / 100.0 54 | 55 | 56 | def _get_alsa_mixer(): 57 | """ 58 | Get a handle on the ALSA mixer 59 | 60 | :rtype: alsaaudio.Mixer 61 | :return: 62 | The mixer. 63 | """ 64 | # Get the ALSA mixer by going in the order that they are listed 65 | for mixer in alsaaudio.mixers(): 66 | try: 67 | return alsaaudio.Mixer(mixer) 68 | except: 69 | pass 70 | 71 | # With no mixer we can't do anything 72 | raise ValueError("No mixer found") 73 | -------------------------------------------------------------------------------- /core/event.py: -------------------------------------------------------------------------------- 1 | """ 2 | Events within the system. 3 | """ 4 | 5 | from dexter.core.log import LOG 6 | 7 | import sys 8 | import time 9 | 10 | # ------------------------------------------------------------------------------ 11 | 12 | class Event(object): 13 | """ 14 | The event base type. 15 | """ 16 | def __init__(self, creation_time=None, runnable=None): 17 | """ 18 | :type creation_time: float, or None 19 | :param creation_time: 20 | The time at which this event was created, or None if it should be 21 | now. In seconds since epoch. 22 | :type runnable: function () -> L{Event} 23 | :param runnable: 24 | A lambda to invoke when this event is handled. It may return another 25 | L{Event} instance, or C{None}. 26 | """ 27 | self._creation_time = \ 28 | float(creation_time) if creation_time is not None else time.time() 29 | self._runnable = runnable 30 | 31 | 32 | @property 33 | def creation_time(self): 34 | """ 35 | The time at which this event was created, in seconds since epoch. 36 | """ 37 | return self._creation_time 38 | 39 | 40 | def invoke(self): 41 | """ 42 | Invoke this event. 43 | 44 | :rtype: Event, or None 45 | :return: 46 | Invoke this event to do its job. It may return another event as 47 | a result, which will be scheduled for handling. 48 | """ 49 | if self._runnable is not None: 50 | return self._runnable() 51 | else: 52 | return None 53 | 54 | 55 | class TimerEvent(Event): 56 | """ 57 | An event which is set to fire at a given time. 58 | """ 59 | def __init__(self, schedule_time, runnable=None): 60 | """ 61 | @see L{Event.__init__} 62 | 63 | :type schedule_time: float 64 | @parse schedule_time: 65 | The time at which this event should fire. In seconds since epoch. 66 | """ 67 | super().__init__(runnable=runnable) 68 | self._schedule_time = float(schedule_time) 69 | 70 | 71 | @property 72 | def schedule_time(self): 73 | """ 74 | The time at which this event should be scheduled, in seconds since 75 | epoch. 76 | """ 77 | return self._schedule_time 78 | -------------------------------------------------------------------------------- /core/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | How we log in the system. 3 | """ 4 | 5 | import logging 6 | 7 | # Keep it simple for now 8 | LOG = logging 9 | 10 | LOG.basicConfig( 11 | format='[%(asctime)s %(threadName)s %(filename)s:%(lineno)d %(levelname)s] %(message)s', 12 | level=logging.INFO 13 | ) 14 | -------------------------------------------------------------------------------- /core/player.py: -------------------------------------------------------------------------------- 1 | """ 2 | How we play media (like music). 3 | """ 4 | 5 | from dexter.core.audio import MIN_VOLUME, MAX_VOLUME 6 | from dexter.core.log import LOG 7 | from dexter.core.util import get_pygame 8 | from threading import Thread 9 | 10 | import queue 11 | import time 12 | 13 | # ------------------------------------------------------------------------------ 14 | 15 | class SimpleMP3Player(object): 16 | """ 17 | A simple mp3 layer, local files only. 18 | """ 19 | def __init__(self): 20 | """ 21 | Constructor. 22 | """ 23 | # What we're playing, and what we've played. These are all filenames. 24 | self._queue = queue.Queue() 25 | 26 | # If we're paused 27 | self._paused = False 28 | 29 | # Set the controller thread going 30 | thread = Thread(name='MP3Player', target=self._controller) 31 | thread.daemon = True 32 | thread.start() 33 | 34 | 35 | def set_volume(self, volume): 36 | """ 37 | Set the volume to a value between zero and eleven. 38 | 39 | :type value: float 40 | :param value: 41 | The volume level to set. This should be between `MIN_VOLUME` and 42 | `MAX_VOLUME` inclusive. 43 | """ 44 | volume = float(value) 45 | 46 | if volume < MIN_VOLUME or volume > MAX_VOLUME: 47 | raise ValueError("Volume out of [%d..%d] range: %s" % 48 | (MIN_VOLUME, MAX_VOLUME, value)) 49 | 50 | # Set as a fraction of 1 51 | v = (volume / MAX_VOLUME) 52 | LOG.info("Setting volume to %0.2f" % v) 53 | get_pygame().mixer.music.set_volume(v) 54 | 55 | 56 | def get_volume(self): 57 | """ 58 | Get the current volume, as a value between zero and eleven. 59 | 60 | :rtype: float 61 | :return: 62 | The volume level; between `MIN_VOLUME` and `MAX_VOLUME` inclusive. 63 | """ 64 | return MAX_VOLUME * get_pygame().mixer.music.get_volume() 65 | 66 | 67 | def is_playing(self): 68 | """ 69 | Return whether we are currently playing anything. 70 | 71 | :rtype: bool 72 | :return: 73 | Whether the player is playing. 74 | """ 75 | return get_pygame().mixer.music.get_busy() and not self._paused 76 | 77 | 78 | def is_paused(self): 79 | """ 80 | Return whether we are currently paused. 81 | 82 | :rtype: bool 83 | :return: 84 | Whether the player is paused. 85 | """ 86 | return self._paused 87 | 88 | 89 | def play_files(self, filenames): 90 | """ 91 | Play a list of files. 92 | 93 | :type filenames: tuple(str) 94 | :param filenames: 95 | The list of filenames to play. 96 | """ 97 | # First we stop everything 98 | self.stop() 99 | 100 | # Make sure that we have at least one file 101 | if len(filenames) == 0: 102 | return 103 | 104 | # Now we load the first file. We do this directly so that errors may 105 | # propagate. 106 | filename = filenames[0] 107 | LOG.info("Playing %s", filename) 108 | get_pygame().mixer.music.load(filename) 109 | get_pygame().mixer.music.play() 110 | 111 | # And enqueue the rest 112 | for filename in filenames[1:]: 113 | self._queue.put(filename) 114 | 115 | 116 | def stop(self): 117 | """ 118 | Stop all the music and clear the queue. 119 | """ 120 | # If we're stopped then we're not paused 121 | self._paused = False 122 | 123 | # Empty the queue by replacing the current one with an empty one. 124 | self._queue = queue.Queue() 125 | 126 | # Tell pygame to stop playing any current music 127 | try: 128 | get_pygame().mixer.music.stop() 129 | except: 130 | pass 131 | 132 | 133 | def pause(self): 134 | """ 135 | Pause any currently playing music. 136 | """ 137 | if self.is_playing(): 138 | self._paused = True 139 | get_pygame().mixer.music.pause() 140 | 141 | 142 | def unpause(self): 143 | """ 144 | Resume any currently paused music. 145 | """ 146 | self._paused = False 147 | get_pygame().mixer.music.unpause() 148 | 149 | 150 | def _controller(self): 151 | """ 152 | The main controller thread. This handles keeping things going in the 153 | background. It does not do much heavy lifting however. 154 | """ 155 | while True: 156 | # Tum-ti-tum 157 | time.sleep(0.1) 158 | 159 | # Do nothing while the song is playing. 160 | if get_pygame().mixer.music.get_busy(): 161 | continue 162 | 163 | # Get the next song to play, this will block 164 | song = self._queue.get() 165 | LOG.info("Playing %s", song) 166 | 167 | # Attempt to play it 168 | try: 169 | get_pygame().mixer.music.load(song) 170 | get_pygame().mixer.music.play() 171 | except Exception as e: 172 | LOG.warning("Failed to play %s: %s", song, e) 173 | -------------------------------------------------------------------------------- /deepspeech_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Listen for incoming data and give back parsed results. 4 | 5 | This can be run a machine with a decent amount of oomph and the L{RemoteService} 6 | can talk to it instead of doing the speech-to-text locally. This is handy for 7 | when your client machine is just a Raspberry Pi. 8 | 9 | And, if other Home Hub providers can ship audio off from their Home Hub to the 10 | cloud to process, then it seems only fair that we can do something like that 11 | too. 12 | """ 13 | 14 | from deepspeech import Model 15 | from threading import Thread 16 | 17 | import argparse 18 | import logging 19 | import numpy 20 | import os 21 | import socket 22 | import struct 23 | import time 24 | 25 | # ------------------------------------------------------------------------------ 26 | 27 | def handle(conn): 28 | """ 29 | Create a new thread for the given connection and start it. 30 | """ 31 | thread = Thread(target=lambda: run(conn)) 32 | thread.daemon = True 33 | thread.start() 34 | 35 | 36 | def run(conn): 37 | """ 38 | Handle a new connection, in its own thread. 39 | """ 40 | try: 41 | # Read in the header data 42 | logging.info("Reading header") 43 | header = b'' 44 | while len(header) < (3 * 8): 45 | got = conn.recv((3 * 8) - len(header)) 46 | if len(got) == 0: 47 | time.sleep(0.001) 48 | continue 49 | header += got 50 | 51 | # Unpack to variables 52 | (channels, width, rate) = struct.unpack('!qqq', header) 53 | logging.info("%d channel(s), %d byte(s) wide, %dHz" % 54 | (channels, width, rate)) 55 | 56 | if model.sampleRate() != rate: 57 | raise ValueError("Given sample rate, %d, differs from desired rate, %d" % 58 | (rate, model.sampleRate())) 59 | if width not in (1, 2, 4, 8): 60 | raise ValueError("Unhandled width: %d" % width) 61 | 62 | # Decode as we go 63 | context = model.createStream() 64 | 65 | # Keep pulling in the data until we get an empty chunk 66 | total_size = 0 67 | while True: 68 | # How big is this incoming chunk? 69 | length_bytes = b'' 70 | while len(length_bytes) < (8): 71 | got = conn.recv(8 - len(length_bytes)) 72 | if len(got) == 0: 73 | time.sleep(0.001) 74 | continue 75 | length_bytes += got 76 | (length,) = struct.unpack('!q', length_bytes) 77 | 78 | # End marker? 79 | if length < 0: 80 | break 81 | 82 | # Pull in the chunk 83 | logging.debug("Reading %d bytes of data" % (length,)) 84 | data = b'' 85 | while len(data) < length: 86 | got = conn.recv(length - len(data)) 87 | if len(got) == 0: 88 | time.sleep(0.001) 89 | continue 90 | data += got 91 | 92 | # Feed it in 93 | if width == 1: audio = numpy.frombuffer(data, numpy.int8) 94 | elif width == 2: audio = numpy.frombuffer(data, numpy.int16) 95 | elif width == 4: audio = numpy.frombuffer(data, numpy.int32) 96 | elif width == 8: audio = numpy.frombuffer(data, numpy.int64) 97 | context.feedAudioContent(audio) 98 | total_size += len(data) 99 | 100 | # Finally, decode it 101 | logging.info("Decoding %0.2f seconds of audio", 102 | total_size / rate / width / channels) 103 | words = context.finishStream() 104 | logging.info("Got: '%s'" % (words,)) 105 | 106 | # Send back the length (as a long) and the string 107 | words = words.encode() 108 | conn.sendall(struct.pack('!q', len(words))) 109 | conn.sendall(words) 110 | 111 | except Exception as e: 112 | # Tell the user at least 113 | logging.error("Error handling incoming data: %s" % e) 114 | 115 | finally: 116 | # We're done with this connection now, close in a best-effort fashion 117 | try: 118 | logging.info("Closing connection") 119 | conn.shutdown(socket.SHUT_RDWR) 120 | conn.close() 121 | except: 122 | pass 123 | 124 | 125 | # ------------------------------------------------------------------------------ 126 | 127 | 128 | # Set up the logger 129 | logging.basicConfig( 130 | format='[%(asctime)s %(threadName)s %(filename)s:%(lineno)d %(levelname)s] %(message)s', 131 | level=logging.INFO 132 | ) 133 | 134 | # Parse the command line args 135 | parser = argparse.ArgumentParser(description='Running DeepSpeech inference.') 136 | parser.add_argument('--model', required=True, 137 | help='Path to the .pbmm file') 138 | parser.add_argument('--scorer', required=False, 139 | help='Path to the .scorer file') 140 | parser.add_argument('--beam_width', type=int, default=500, 141 | help='Beam width for the CTC decoder') 142 | parser.add_argument('--port', type=int, default=8008, 143 | help='The port number to listen on') 144 | args = parser.parse_args() 145 | 146 | # Load in the model 147 | logging.info("Loading model from %s" % args.model) 148 | model = Model(args.model) 149 | 150 | # Configure it 151 | model.setBeamWidth(args.beam_width) 152 | if args.scorer: 153 | logging.info("Loading scorer from %s" % (args.scorer,)) 154 | model.enableExternalScorer(args.scorer) 155 | 156 | # Set up the server socket 157 | logging.info("Opening socket on port %d" % (args.port,)) 158 | sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 159 | sckt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 160 | sckt.bind(('0.0.0.0', args.port)) 161 | sckt.listen(5) 162 | 163 | # Do this forever 164 | while True: 165 | try: 166 | # Get a connection 167 | logging.info("Waiting for a connection") 168 | (conn, addr) = sckt.accept() 169 | logging.info("Got connection from %s" % (addr,)) 170 | handle(conn) 171 | 172 | except Exception as e: 173 | # Tell the user at least 174 | logging.error("Error handling incoming connection: %s" % e) 175 | -------------------------------------------------------------------------------- /dexter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argh 4 | import getpass 5 | import logging 6 | import pyjson5 7 | import os 8 | import socket 9 | import sys 10 | 11 | sys.path[0] += '/..' 12 | 13 | from dexter.core import Dexter 14 | from dexter.core.log import LOG 15 | 16 | # ------------------------------------------------------------------------------ 17 | 18 | # A very basic configuration which should work on most things if people have 19 | # installed the various requirements. Not very exciting though. 20 | CONFIG = { 21 | 'key_phrases' : ( 22 | "Hey Computer", 23 | "Hey Dexter", 24 | ), 25 | 'components' : { 26 | 'inputs' : ( 27 | ( 28 | 'dexter.input.socket.SocketInput', 29 | { 30 | 'port' : '8008' 31 | } 32 | ), 33 | ), 34 | 'outputs' : ( 35 | ( 36 | 'dexter.output.io.LogOutput', 37 | { 38 | 'level' : 'INFO' 39 | } 40 | ), 41 | ( 42 | 'dexter.output.festvox.FestivalOutput', 43 | None 44 | ), 45 | ), 46 | 'services' : ( 47 | ( 48 | 'dexter.service.dev.EchoService', 49 | None 50 | ), 51 | ), 52 | } 53 | } 54 | 55 | # ------------------------------------------------------------------------------ 56 | 57 | def _replace_envvars(value): 58 | """ 59 | Replace any environment variables in the given type. 60 | 61 | :return: A deep copy of the type with the values replaced. 62 | """ 63 | if type(value) == str: 64 | return _replace_envvars_str(value) 65 | elif type(value) == tuple or type(value) == list: 66 | return _replace_envvars_list(value) 67 | elif type(value) == dict: 68 | return _replace_envvars_dict(value) 69 | else: 70 | return value 71 | 72 | 73 | def _replace_envvars_str(value): 74 | """ 75 | Look for environment variables in the form of ``${VARNAME}`` and replace them 76 | with their values. This isn't overly pretty, but it works. 77 | """ 78 | try: 79 | while True: 80 | # Pull out the variable name, if it exists 81 | start = value.index('${') 82 | end = value.index('}', start) 83 | varname = value[start+2:end] 84 | varvalue = os.environ.get(varname, '') 85 | 86 | # Special handling for some variables 87 | if not varvalue: 88 | # These are not always set in the environment but 89 | # people tend to expect it to be, so we are nice and 90 | # provide them 91 | if varname == "HOSTNAME": 92 | varvalue = socket.gethostname() 93 | elif varname == "USER": 94 | varvalue = getpass.getuser() 95 | 96 | # And replace it 97 | value = (value[:start] + varvalue + value[end+1:]) 98 | except: 99 | # This means we failed to find the opening or closing 100 | # varname container in the string, so we're done 101 | pass 102 | 103 | # Give it back 104 | return value 105 | 106 | 107 | def _replace_envvars_list(value): 108 | try: 109 | return [_replace_envvars(v) for v in value] 110 | except: 111 | return value 112 | 113 | 114 | def _replace_envvars_dict(value): 115 | try: 116 | return dict((k, _replace_envvars(v)) for (k,v) in value.items()) 117 | except: 118 | return value 119 | 120 | 121 | # ------------------------------------------------------------------------------ 122 | 123 | # Main entry point 124 | @argh.arg('--log-level', '-L', 125 | help="The logging level to use") 126 | @argh.arg('--config', '-c', 127 | help="The JSON configuration file to use") 128 | def main(log_level=None, config=None): 129 | """ 130 | Dexter is a personal assistant which responds to natural language for its 131 | commands. 132 | """ 133 | # Set the log level, if supplied 134 | if log_level is not None: 135 | try: 136 | LOG.getLogger().setLevel(int(log_level)) 137 | except: 138 | LOG.getLogger().setLevel(log_level.upper()) 139 | 140 | # Load in any configuration 141 | if config is not None: 142 | try: 143 | with open(config) as fh: 144 | configuration = pyjson5.load(fh) 145 | except Exception as e: 146 | LOG.fatal("Failed to parse config file '%s': %s" % (config, e)) 147 | sys.exit(1) 148 | else: 149 | configuration = CONFIG 150 | 151 | # Handle environment variables in the component kwargs 152 | for typ in configuration['components']: 153 | # We might have kwargs for all the components 154 | for component in configuration['components'][typ]: 155 | (which, kwargs) = component 156 | if kwargs is None: 157 | continue 158 | 159 | # Look at all the kwargs which we have and check for environment 160 | # variables in the value names. 161 | updated = _replace_envvars(kwargs) 162 | for name in kwargs: 163 | # If we changed it then save it back in 164 | if updated[name] != kwargs[name]: 165 | LOG.info( 166 | "Expanded component %s:%s:%s argument from '%s' to '%s'" % 167 | (typ, which, name, kwargs[name], updated[name]) 168 | ) 169 | kwargs[name] = updated[name] 170 | 171 | # And spawn it 172 | dexter = Dexter(configuration) 173 | dexter.run() 174 | 175 | 176 | # ------------------------------------------------------------------------------ 177 | 178 | if __name__ == "__main__": 179 | try: 180 | argh.dispatch_command(main) 181 | except Exception as e: 182 | print("%s" % e) 183 | -------------------------------------------------------------------------------- /dexter.service: -------------------------------------------------------------------------------- 1 | # A sample systemd config file. To make this work do the following: 2 | # - Edit the username (all the 'pi's) and Dexter config in the below to match 3 | # what you want 4 | # - Add environment variables REC_NAME and PLAY_NAME to the environment 5 | # variable names if you want to auto-generate the .asoundrc file. See 6 | # the run and make_asoundrc scripts for more info. 7 | # - Copy the file to /etc/systemd/system/ 8 | # - Enable and start it: 9 | # - sudo systemctl enable dexter 10 | # - sudo systemctl start dexter 11 | 12 | [Unit] 13 | Description=Dexter 14 | After=network.target 15 | 16 | [Service] 17 | Type=simple 18 | User=pi 19 | WorkingDirectory=/home/pi/dexter 20 | ExecStart=env CONFIG=/home/pi/dexter/pi_config /home/pi/dexter/run 21 | Restart=always 22 | 23 | [Install] 24 | WantedBy=multi-user.target 25 | -------------------------------------------------------------------------------- /dolly_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Listen for incoming strings and give back a response. 4 | """ 5 | 6 | from threading import Lock, Thread 7 | from transformers import pipeline 8 | 9 | import argparse 10 | import logging 11 | import numpy 12 | import os 13 | import socket 14 | import struct 15 | import time 16 | import torch 17 | 18 | # ------------------------------------------------------------------------------ 19 | 20 | _MODELS = ('dolly-v2-3b', ) 21 | _LOCK = Lock() 22 | 23 | # ------------------------------------------------------------------------------ 24 | 25 | def handle(conn, translate): 26 | """ 27 | Create a new thread for the given connection and start it. 28 | """ 29 | thread = Thread(target=lambda: run(conn, generator)) 30 | thread.daemon = True 31 | thread.start() 32 | 33 | 34 | def run(conn, generator): 35 | """ 36 | Handle a new connection, in its own thread. 37 | """ 38 | try: 39 | # Read in the header data 40 | logging.info("Waiting for data") 41 | in_bytes = b'' 42 | while True 43 | got = conn.recv(1) 44 | if len(got) == 0: 45 | time.sleep(0.001) 46 | continue 47 | if got = '\0': 48 | break 49 | in_bytes += got 50 | 51 | # Turn it into a str 52 | in_str = in_bytes.decode() 53 | logging.info("Handling '%s'", in_str) 54 | 55 | # Only one at a time so do this under a lock 56 | with _LOCK: 57 | result = generator(in_str) 58 | 59 | # Send back the length (as a long) and the string 60 | result = result.encode() 61 | conn.sendall(result) 62 | conn.send('\0') 63 | 64 | except Exception as e: 65 | # Tell the user at least 66 | logging.error("Error handling incoming data: %s", e) 67 | 68 | finally: 69 | # We're done with this connection now, close in a best-effort fashion 70 | try: 71 | logging.info("Closing connection") 72 | conn.shutdown(socket.SHUT_RDWR) 73 | conn.close() 74 | except: 75 | pass 76 | 77 | 78 | # ------------------------------------------------------------------------------ 79 | 80 | 81 | # Set up the logger 82 | logging.basicConfig( 83 | format='[%(asctime)s %(threadName)s %(filename)s:%(lineno)d %(levelname)s] %(message)s', 84 | level=logging.INFO 85 | ) 86 | 87 | # Parse the command line args 88 | parser = argparse.ArgumentParser(description='Running Dolly LLM.') 89 | parser.add_argument('--model', default='dolly-v2-3b', 90 | help='The model type to use; one of %s' % ' '.join(_MODELS)) 91 | parser.add_argument('--port', type=int, default=8008, 92 | help='The port number to listen on') 93 | args = parser.parse_args() 94 | 95 | # Pull in the model 96 | logging.info("Loading '%s' model", args.model,) 97 | generator = pipeline(model ="databricks/%s" % args.model, 98 | torch_dtype =torch.bfloat16, 99 | trust_remote_code=True, 100 | device_map ="auto") 101 | 102 | # Set up the server socket 103 | logging.info("Opening socket on port %d", args.port) 104 | sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 105 | sckt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 106 | sckt.bind(('0.0.0.0', args.port)) 107 | sckt.listen(5) 108 | 109 | # Do this forever 110 | while True: 111 | try: 112 | # Get a connection 113 | logging.info("Waiting for a connection") 114 | (conn, addr) = sckt.accept() 115 | logging.info("Got connection from %s" % (addr,)) 116 | handle(conn, generator) 117 | 118 | except Exception as e: 119 | # Tell the user at least 120 | logging.error("Error handling incoming connection: %s", e) 121 | -------------------------------------------------------------------------------- /dot.asoundrc.headless: -------------------------------------------------------------------------------- 1 | # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 2 | # !!! This appears to have stopped working on the Raspberry Pi with the latest !!! 3 | # !!! updates. For now, just use the Ubuntu Desktop one, which seems okay. !!! 4 | # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 5 | 6 | # An example ALSA config file which uses a microphone plugged into the USB port 7 | # (usually the second device) and assumes that the speakers are the first 8 | # device. 9 | # 10 | # This config works for headless devices. 11 | # 12 | # Running: 13 | # arecord -l 14 | # aplay -l 15 | # should help you identify the values for the PCM strings, which are of the 16 | # form: 17 | # "hw:" 18 | # 19 | # Beware that the ordering can change though so, if nothing seems to be working 20 | # check the card lists again. 21 | # 22 | # We configure things so that they may be shared between processes. 23 | 24 | pcm.!default { 25 | type plug 26 | slave.pcm "duplex" 27 | } 28 | 29 | pcm.duplex { 30 | type asym 31 | capture.pcm "input" 32 | playback.pcm "output" 33 | } 34 | 35 | pcm.output { 36 | type dmix 37 | ipc_key 1024 38 | slave { 39 | pcm "hw:0,0" 40 | period_time 0 41 | period_size 1024 42 | buffer_size 8192 43 | rate 44100 44 | } 45 | bindings { 46 | 0 0 47 | 1 1 48 | } 49 | } 50 | 51 | 52 | pcm.input { 53 | type asym 54 | slave.pcm "hw:1,0" 55 | } 56 | -------------------------------------------------------------------------------- /dot.asoundrc.ubuntu-desktop: -------------------------------------------------------------------------------- 1 | # An example ALSA config file which uses a microphone plugged into the USB port 2 | # (usually the second device) and assumes that the speakers are the first 3 | # device. 4 | # 5 | # This config works for the current Ubuntu 22.10 Desktop distribution. 6 | # 7 | # Running: 8 | # arecord -l 9 | # aplay -l 10 | # should help you identify the values for the PCM strings, which are of the 11 | # form: 12 | # "hw:" 13 | # 14 | # Beware that the ordering can change though so, if nothing seems to be working 15 | # check the card lists again. 16 | # 17 | pcm.!default { 18 | type asym 19 | playback.pcm { 20 | type plug 21 | slave.pcm "hw:0,0" 22 | } 23 | capture.pcm { 24 | type plug 25 | slave.pcm "hw:1,0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /gui/images/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamsrp/dexter/01a4644044ab174257f962ddc6e42ca7fef5fa26/gui/images/stop.png -------------------------------------------------------------------------------- /gui/images/stop_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamsrp/dexter/01a4644044ab174257f962ddc6e42ca7fef5fa26/gui/images/stop_pressed.png -------------------------------------------------------------------------------- /gui/screens/__init__.py: -------------------------------------------------------------------------------- 1 | from dexter.gui.widgets.button import StopButton 2 | from dexter.gui.widgets.background import Background 3 | from dexter.gui.widgets.clock import MainClock 4 | from dexter.gui.widgets.scroller import Scroller 5 | from dexter.gui.widgets.volume import Volume 6 | from kivy.lang import Builder 7 | from kivy.properties import NumericProperty, ObjectProperty, StringProperty 8 | from kivy.uix.floatlayout import FloatLayout 9 | 10 | class MainScreen(FloatLayout): 11 | """ 12 | The class for Dexter's main screen. 13 | 14 | This is mostly configured to run with the standard ``800x600`` screen 15 | size. If you want to run with something different then tweak the scaling 16 | values in the GUI configuration. 17 | """ 18 | # Widgets which we need access to 19 | inputs_widget = ObjectProperty(None) 20 | outputs_widget = ObjectProperty(None) 21 | scroller_widget = ObjectProperty(None) 22 | services_widget = ObjectProperty(None) 23 | stop_widget = ObjectProperty(None) 24 | volume_widget = ObjectProperty(None) 25 | 26 | # Configuration 27 | scale = NumericProperty(1.0, rebind=True) 28 | message_scale = NumericProperty(1.0, rebind=True) 29 | clock_scale = NumericProperty(1.0, rebind=True) 30 | clock_fmt = StringProperty ('24h', rebind=True) 31 | 32 | def __init__(self, 33 | scale, 34 | message_scale, 35 | clock_scale, 36 | clock_format): 37 | """ 38 | CTOR 39 | 40 | :param scale: 41 | The overall scaling for the GUI. 42 | :param message_scale: 43 | The scaling for the message window. 44 | :param clock_scale: 45 | The scaling for the clock. 46 | :param clock_format: 47 | The clock format, either ``12h`` or ``24h``. 48 | """ 49 | super().__init__() 50 | self.scale = scale 51 | self.message_scale = message_scale 52 | self.clock_scale = clock_scale 53 | self.clock_format = clock_format 54 | 55 | 56 | Builder.load_string(""" 57 | 58 | scroller_widget: scroller 59 | stop_widget: stop 60 | volume_widget: volume 61 | inputs_widget: inputs 62 | outputs_widget: outputs 63 | services_widget: services 64 | 65 | BoxLayout: 66 | BoxLayout: 67 | orientation: 'vertical' 68 | halign: 'center' 69 | size_hint_x: 0.8 70 | padding: 5 71 | spacing: 5 72 | 73 | BoxLayout: 74 | size_hint_y: 0.2 * root.scale * root.clock_scale 75 | MainClock: 76 | font_size: 60 * root.scale * root.clock_scale 77 | format: root.clock_fmt 78 | bold: True 79 | halign: 'center' 80 | valign: 'top' 81 | 82 | Volume: 83 | id: volume 84 | valign: 'top' 85 | size_hint_y: 0.1 * root.scale 86 | orientation: 'horizontal' 87 | 88 | BoxLayout: 89 | size_hint_y: 0.1 90 | Label: 91 | id: inputs 92 | font_size: 30 * root.scale 93 | bold: True 94 | halign: 'center' 95 | Label: 96 | id: services 97 | font_size: 30 * root.scale 98 | bold: True 99 | halign: 'center' 100 | Label: 101 | id: outputs 102 | font_size: 30 * root.scale 103 | bold: True 104 | halign: 'center' 105 | 106 | Scroller: 107 | id: scroller 108 | pos_hint: { 'center_x' : 0.5 } 109 | size_hint_y: 0.8 110 | font_size: 25 * root.scale * root.message_scale 111 | halign: 'center' 112 | 113 | BoxLayout: 114 | pos_hint: { 'center_x' : .5 } 115 | size_hint_x: 0.17 116 | size_hint_y: 0.3 117 | 118 | StopButton: 119 | id: stop 120 | halign: 'right' 121 | """) 122 | -------------------------------------------------------------------------------- /gui/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamsrp/dexter/01a4644044ab174257f962ddc6e42ca7fef5fa26/gui/widgets/__init__.py -------------------------------------------------------------------------------- /gui/widgets/background.py: -------------------------------------------------------------------------------- 1 | from kivy.lang import Builder 2 | from kivy.properties import StringProperty, NumericProperty, ListProperty 3 | from kivy.uix.effectwidget import EffectWidget 4 | 5 | 6 | class Background(EffectWidget): 7 | uri = StringProperty(None, allownone=True) 8 | default = StringProperty() 9 | blur_size = NumericProperty(64) 10 | outbounds = NumericProperty(0) 11 | 12 | 13 | Builder.load_string(""" 14 | #:import HBlur kivy.uix.effectwidget.HorizontalBlurEffect 15 | #:import VBlur kivy.uix.effectwidget.VerticalBlurEffect 16 | #:import Window kivy.core.window.Window 17 | 18 | 19 | effects: (HBlur(size=10), VBlur(size=10), HBlur(size=self.blur_size), VBlur(size=self.blur_size)) 20 | size_hint: (1 + root.outbounds, 1 + root.outbounds) 21 | x: - Window.width * root.outbounds/2.0 22 | y: - Window.height * root.outbounds/2.0 23 | """) 24 | -------------------------------------------------------------------------------- /gui/widgets/button.py: -------------------------------------------------------------------------------- 1 | from kivy.lang import Builder 2 | from kivy.uix.button import Button 3 | 4 | class StopButton(Button): 5 | def __init__(self, **kwargs): 6 | super().__init__(**kwargs) 7 | self._callback = None 8 | 9 | 10 | def set_callback(self, callback): 11 | """ 12 | Set the function to be called when the button is pressed. 13 | """ 14 | self._callback = callback 15 | 16 | 17 | def on_press(self, *args): 18 | if self._callback: 19 | self._callback() 20 | 21 | 22 | Builder.load_string(""" 23 | : 24 | background_normal: 'stop.png' 25 | background_down: 'stop_pressed.png' 26 | """) 27 | -------------------------------------------------------------------------------- /gui/widgets/clock.py: -------------------------------------------------------------------------------- 1 | from kivy.properties import StringProperty 2 | from kivy.clock import Clock 3 | from kivy.uix.label import Label 4 | 5 | import time 6 | 7 | # ---------------------------------------------------------------------- 8 | 9 | class MainClock(Label): 10 | format = StringProperty('24h') 11 | 12 | def __init__(self, **kwargs): 13 | super().__init__(**kwargs) 14 | Clock.schedule_interval(self.update_clock, 0.1) 15 | 16 | 17 | def update_clock(self, *args): 18 | if self.format == '12h': 19 | fmt = '%r' 20 | elif self.format == '24h': 21 | fmt = '%T' 22 | else: 23 | raise ValueError("Bad time format '%s'" % self.format) 24 | self.text = time.strftime(fmt, time.localtime(time.time())) 25 | -------------------------------------------------------------------------------- /gui/widgets/scroller.py: -------------------------------------------------------------------------------- 1 | from kivy.clock import Clock 2 | from kivy.lang import Builder 3 | from kivy.properties import StringProperty, NumericProperty 4 | from kivy.uix.effectwidget import EffectWidget 5 | from kivy.uix.scrollview import ScrollView 6 | from threading import Lock 7 | 8 | import time 9 | 10 | # ------------------------------------------------------------------------------ 11 | 12 | class Scroller(ScrollView): 13 | """ 14 | A little scrolling pane to display messages. 15 | """ 16 | # Age before we drop the text 17 | _MAX_AGE = 30 18 | _lock = Lock() 19 | _lines = [] 20 | 21 | # The text to display 22 | text = StringProperty ('', rebind=True) 23 | font_size = NumericProperty(20, rebind=True) 24 | 25 | def __init__(self, **kwargs): 26 | super().__init__(**kwargs) 27 | Clock.schedule_interval(self._update, 0.1) 28 | 29 | 30 | def _update(self, *args): 31 | """ 32 | Do housekeeping. 33 | """ 34 | # Do all this under the lock so that we don't trip over other folks 35 | # adding things 36 | changed = False 37 | with self._lock: 38 | # Trim away any old lines 39 | limit = time.time() 40 | while len(self._lines) > 0 and self._lines[0][0] < limit: 41 | self._lines.pop(0) 42 | changed = True 43 | 44 | # And trim away any blank lines at the top 45 | while len(self._lines) > 0 and len(self._lines[0][1].strip()) == 0: 46 | self._lines.pop(0) 47 | changed = True 48 | 49 | # And update the text box if anything changed 50 | if changed: 51 | self._render() 52 | 53 | 54 | def set_text(self, text): 55 | # Nothing breeds nothing 56 | if not text: 57 | return 58 | 59 | # Do this under the lock so we don't triup over the timer 60 | with self._lock: 61 | # We timestamp all the lines so that we may decay them away 62 | expiry = time.time() + self._MAX_AGE 63 | 64 | # And set the lines and have them slowly decay away 65 | self._lines = [] 66 | for line in text.split('\n'): 67 | if len(line.strip()) > 0: 68 | self._lines.append((expiry, line)) 69 | expiry += 0.3 70 | 71 | # And update the text box 72 | self._render() 73 | 74 | 75 | def _render(self): 76 | """ 77 | Render the lines into text. 78 | """ 79 | # Do this under the lock so we don't trip over the timer 80 | with self._lock: 81 | self.text = '\n'.join(line for (now, line) in self._lines) 82 | self.scroll_y = 0.0 83 | 84 | # ------------------------------------------------------------------------------ 85 | 86 | Builder.load_string(""" 87 | : 88 | do_scroll_x: True 89 | do_scroll_y: True 90 | 91 | Label: 92 | size_hint_y: None 93 | height: self.texture_size[1] 94 | font_name: 'FreeMonoBold' 95 | font_size: self.parent.font_size 96 | text_size: self.width * 0.9, None 97 | padding: 10, 10 98 | text: self.parent.text 99 | halign: 'center' 100 | """) 101 | -------------------------------------------------------------------------------- /gui/widgets/volume.py: -------------------------------------------------------------------------------- 1 | from dexter.core.audio import MIN_VOLUME, MAX_VOLUME, get_volume, set_volume 2 | from kivy.clock import Clock 3 | from kivy.lang import Builder 4 | from kivy.properties import NumericProperty 5 | from kivy.uix.slider import Slider 6 | 7 | 8 | class Volume(Slider): 9 | MIN_VOL = MIN_VOLUME 10 | MAX_VOL = MAX_VOLUME 11 | 12 | def __init__(self, **kwargs): 13 | super().__init__(**kwargs) 14 | Clock.schedule_interval(self._update, 0.2) 15 | 16 | 17 | def _update(self, *args): 18 | vol = get_volume() 19 | if self.value != vol: 20 | self.value = vol 21 | 22 | 23 | def on_value(self, *args): 24 | try: 25 | set_volume(self.value) 26 | except ValueError as e: 27 | LOG.error("Failed to set volume to %d: %s", self.value, e) 28 | 29 | 30 | Builder.load_string(""" 31 | : 32 | min: root.MIN_VOL 33 | max: root.MAX_VOL 34 | step: 1 35 | """) 36 | -------------------------------------------------------------------------------- /input/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /input/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | How Dexter gets instructions from outside world. 3 | 4 | This might be via speech recognition, a network connection, etc. 5 | """ 6 | 7 | from dexter.core import Component 8 | 9 | # ------------------------------------------------------------------------------ 10 | 11 | class Token(object): 12 | """ 13 | Part of the input. 14 | """ 15 | def __init__(self, element, probability, verbal): 16 | """ 17 | :type element: str 18 | :param element: 19 | The thing which this token contains. This may be a word or it might 20 | be something like "". This depends on the input mechanism. 21 | :type probability: float 22 | :param probability: 23 | The probability associated with this token. 24 | :type verbal: bool 25 | :param verbal: 26 | Whether the token is a verbal one, or else a semantic one (like 27 | ""). 28 | """ 29 | self._element = element 30 | self._probability = probability 31 | self._verbal = verbal 32 | 33 | 34 | @property 35 | def element(self): 36 | return self._element 37 | 38 | 39 | @property 40 | def probability(self): 41 | return self._probability 42 | 43 | 44 | @property 45 | def verbal(self): 46 | return self._verbal 47 | 48 | 49 | def __str__(self): 50 | if self._verbal: 51 | return "\"%s\"(%0.2f)" % (self._element, self._probability) 52 | else: 53 | return "[%s](%0.2f)" % (self._element, self._probability) 54 | 55 | 56 | class Input(Component): 57 | """ 58 | A way to get text from the outside world. 59 | """ 60 | def __init__(self, state): 61 | """ 62 | @see Component.__init__() 63 | """ 64 | super().__init__(state) 65 | 66 | 67 | @property 68 | def is_input(self): 69 | """ 70 | Whether this component is an input. 71 | """ 72 | return True 73 | 74 | 75 | def read(self): 76 | """ 77 | A non-blocking call to get a list of C{element}s from the outside world. 78 | 79 | Each C{element} is either a L{str} representing a word or a L{Token}. 80 | 81 | :rtype: tuple(C{element}) 82 | :return: 83 | The list of elements received from the outside world, or None if 84 | nothing was available. 85 | """ 86 | # Subclasses should implement this 87 | raise NotImplementedError("Abstract method called") 88 | -------------------------------------------------------------------------------- /input/coqui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input using the Coqui STT library. 3 | 4 | See:: 5 | https://github.com/coqui-ai/stt 6 | https://stt.readthedocs.io/en/latest/ 7 | 8 | You'll mainly just need to download the appropriate model and scorer 9 | files. These can be found either by running the ``stt-model-manager`` or by 10 | going to: 11 | https://coqui.ai/models 12 | """ 13 | 14 | from stt import Model 15 | from dexter.input import Token 16 | from dexter.input.audio import AudioInput 17 | from dexter.core.log import LOG 18 | 19 | import numpy 20 | import os 21 | import pyaudio 22 | import time 23 | 24 | # ------------------------------------------------------------------------------ 25 | 26 | # A typical installation location for Coqui data 27 | _MODEL_DIR = "/usr/local/share/coqui/models" 28 | 29 | # ------------------------------------------------------------------------------ 30 | 31 | class CoquiInput(AudioInput): 32 | """ 33 | Input from Coqui using the given language model. 34 | """ 35 | def __init__(self, 36 | notifier, 37 | rate =None, 38 | wav_dir=None, 39 | model =os.path.join(_MODEL_DIR, 'model'), 40 | scorer =os.path.join(_MODEL_DIR, 'scorer')): 41 | """ 42 | @see AudioInput.__init__() 43 | 44 | :type rate: 45 | :param rate: 46 | The override for the rate, if not the model's one. 47 | :type wav_dir: 48 | :param wav_dir: 49 | Where to save the wave files, if anywhere. 50 | :type model: 51 | :param model: 52 | The path to the Coqui model file. 53 | :type scorer: 54 | :param scorer: 55 | The path to the Coqui scorer file. 56 | """ 57 | # If these don't exist then Coqui will segfault when inferring! 58 | if not os.path.exists(model): 59 | raise IOError("Not found: %s" % (model,)) 60 | 61 | # Load in and configure the model. 62 | start = time.time() 63 | LOG.info("Loading model from %s" % (model,)) 64 | self._model = Model(model) 65 | if os.path.exists(scorer): 66 | LOG.info("Loading scorer from %s" % (scorer,)) 67 | self._model.enableExternalScorer(scorer) 68 | LOG.info("Models loaded in %0.2fs" % (time.time() - start,)) 69 | 70 | # Handle any rate override 71 | if rate is None: 72 | rate = self._model.sampleRate() 73 | 74 | # Wen can now init the superclass 75 | super().__init__( 76 | notifier, 77 | format=pyaudio.paInt16, 78 | channels=1, 79 | rate=rate, 80 | wav_dir=wav_dir 81 | ) 82 | 83 | # Where we put the stream context 84 | self._context = None 85 | 86 | 87 | def _feed_raw(self, data): 88 | """ 89 | @see AudioInput._feed_raw() 90 | """ 91 | if self._context is None: 92 | self._context = self._model.createStream() 93 | audio = numpy.frombuffer(data, numpy.int16) 94 | self._context.feedAudioContent(audio) 95 | 96 | 97 | def _decode(self): 98 | """ 99 | @see AudioInput._decode() 100 | """ 101 | if self._context is None: 102 | # No context means no tokens 103 | LOG.warning("Had no stream context to close") 104 | tokens = [] 105 | else: 106 | # Finish up by finishing the decoding 107 | words = self._context.finishStream() 108 | LOG.info("Got: %s" % (words,)) 109 | self._context = None 110 | 111 | # And tokenize 112 | tokens = [Token(word.strip(), 1.0, True) 113 | for word in words.split(' ') 114 | if len(word.strip()) > 0] 115 | return tokens 116 | -------------------------------------------------------------------------------- /input/deepspeech.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input using DeepSpeech. 3 | 4 | This is considered deprecated in favour of Coqui. 5 | 6 | See https://github.com/mozilla/DeepSpeech 7 | """ 8 | 9 | from deepspeech import Model 10 | from dexter.input import Token 11 | from dexter.input.audio import AudioInput 12 | from dexter.core.log import LOG 13 | 14 | import numpy 15 | import os 16 | import pyaudio 17 | 18 | # ------------------------------------------------------------------------------ 19 | 20 | # Typical installation location for deepspeech data 21 | _MODEL_DIR = "/usr/local/share/deepspeech/models" 22 | 23 | # ------------------------------------------------------------------------------ 24 | 25 | class DeepSpeechInput(AudioInput): 26 | """ 27 | Input from DeepSpeech using the given language model. 28 | """ 29 | def __init__(self, 30 | notifier, 31 | rate=None, 32 | wav_dir=None, 33 | model =os.path.join(_MODEL_DIR, 'models.pbmm'), 34 | scorer=os.path.join(_MODEL_DIR, 'models.scorer')): 35 | """ 36 | @see AudioInput.__init__() 37 | 38 | :type rate: 39 | :param rate: 40 | The override for the rate, if not the model's one. 41 | :type wav_dir: 42 | :param wav_dir: 43 | Where to save the wave files, if anywhere. 44 | :type model: 45 | :param model: 46 | The path to the DeepSpeech model file. 47 | :type scorer: 48 | :param scorer: 49 | The path to the DeepSpeech scorer file. 50 | """ 51 | # If these don't exist then DeepSpeech will segfault when inferring! 52 | if not os.path.exists(model): 53 | raise IOError("Not found: %s" % (model,)) 54 | 55 | # Load in and configure the model. 56 | LOG.info("Loading model from %s" % (model,)) 57 | self._model = Model(model) 58 | if os.path.exists(scorer): 59 | LOG.info("Loading scorer from %s" % (scorer,)) 60 | self._model.enableExternalScorer(scorer) 61 | 62 | # Handle any rate override 63 | if rate is None: 64 | rate = self._model.sampleRate() 65 | 66 | # Wen can now init the superclass 67 | super().__init__(notifier, 68 | format =pyaudio.paInt16, 69 | channels=1, 70 | rate =rate, 71 | wav_dir =wav_dir) 72 | 73 | # Where we put the stream context 74 | self._context = None 75 | 76 | 77 | def _feed_raw(self, data): 78 | """ 79 | @see AudioInput._feed_raw() 80 | """ 81 | if self._context is None: 82 | self._context = self._model.createStream() 83 | audio = numpy.frombuffer(data, numpy.int16) 84 | self._context.feedAudioContent(audio) 85 | 86 | 87 | def _decode(self): 88 | """ 89 | @see AudioInput._decode() 90 | """ 91 | if self._context is None: 92 | # No context means no tokens 93 | LOG.warning("Had no stream context to close") 94 | tokens = [] 95 | else: 96 | # Finish up by finishing the decoding 97 | words = self._context.finishStream() 98 | LOG.info("Got: %s" % (words,)) 99 | self._context = None 100 | 101 | # And tokenize 102 | tokens = [Token(word.strip(), 1.0, True) 103 | for word in words.split(' ') 104 | if len(word.strip()) > 0] 105 | return tokens 106 | -------------------------------------------------------------------------------- /input/digitalio.py: -------------------------------------------------------------------------------- 1 | """ 2 | An input which uses the Adafruit `digitalio` interface. 3 | """ 4 | 5 | from dexter.input import Input, Token 6 | from dexter.core.log import LOG 7 | from digitalio import DigitalInOut, Direction, Pull 8 | 9 | import board 10 | import time 11 | 12 | # ------------------------------------------------------------------------------ 13 | 14 | class VoiceBonnetInput(Input): 15 | """ 16 | A way to get input from the the Adafruit Voice Bonnet. 17 | """ 18 | _BUTTON = board.D17 19 | 20 | def __init__(self, 21 | state, 22 | prefix='Dexter', 23 | button='play or pause'): 24 | """ 25 | @see Input.__init__() 26 | :type prefix: str 27 | :param prefix: 28 | The prefix to use when sending the inputs. 29 | :type button: str 30 | :param button: 31 | What string to send when the button is pressed. 32 | """ 33 | super().__init__(state) 34 | 35 | def tokenize(string): 36 | if string and str(string).strip(): 37 | return [Token(word.strip(), 1.0, True) 38 | for word in str(string).strip().split() 39 | if word] 40 | else: 41 | return [] 42 | 43 | # What we will spit out when pressed 44 | self._output = tokenize(prefix) + tokenize(button) 45 | 46 | # The button, we will set these up in _start() 47 | self._button = None 48 | self._pressed = False 49 | 50 | # And where we put the tokens 51 | self._output = [] 52 | 53 | 54 | def read(self): 55 | """ 56 | @see Input.read 57 | """ 58 | # What we will hand back 59 | result = None 60 | 61 | # Anything? 62 | if not self._button.value: 63 | # Button is pressed 64 | if not self._pressed: 65 | # State changed to pressed 66 | self._pressed = True 67 | result = self._output 68 | elif self._pressed: 69 | # Released 70 | self._pressed = False 71 | 72 | 73 | def _start(self): 74 | """ 75 | @see Component.start() 76 | """ 77 | # Create the button 78 | self._button = DigitalInOut(board.D17) 79 | self._button.direction = Direction.INPUT 80 | self._button.pull = Pull.UP 81 | -------------------------------------------------------------------------------- /input/gpio.py: -------------------------------------------------------------------------------- 1 | """ 2 | An input which uses the Raspberry Pi GPIO interface. 3 | """ 4 | 5 | from dexter.input import Input, Token 6 | from dexter.core.log import LOG 7 | from gpiozero import Button 8 | 9 | import time 10 | 11 | # ------------------------------------------------------------------------------ 12 | 13 | class _HatMiniInput(Input): 14 | """ 15 | A way to get input from the the Pimoroni Scroll HAT Mini. 16 | """ 17 | _A_BUTTON = 5 18 | _B_BUTTON = 6 19 | _X_BUTTON = 16 20 | _Y_BUTTON = 24 21 | 22 | def __init__(self, 23 | state, 24 | prefix ='Dexter', 25 | a_button='raise the volume', 26 | b_button='lower the volume', 27 | x_button='stop', 28 | y_button='play or pause'): 29 | """ 30 | @see Input.__init__() 31 | :type prefix: str 32 | :param prefix: 33 | The prefix to use when sending the inputs. 34 | :type a_button: str 35 | :param a_button: 36 | What string to send when the A button is pressed. 37 | :type b_button: str 38 | :param b_button: 39 | What string to send when the B button is pressed. 40 | :type x_button: str 41 | :param x_button: 42 | What string to send when the X button is pressed. 43 | :type y_button: str 44 | :param y_button: 45 | What string to send when the Y button is pressed. 46 | """ 47 | super().__init__(state) 48 | 49 | def tokenize(string): 50 | if string and str(string).strip(): 51 | return [Token(word.strip(), 1.0, True) 52 | for word in str(string).strip().split() 53 | if word] 54 | else: 55 | return [] 56 | 57 | # Our bindings etc. 58 | self._prefix = tokenize(prefix) 59 | self._bindings = { 60 | self._A_BUTTON : tokenize(a_button), 61 | self._B_BUTTON : tokenize(b_button), 62 | self._X_BUTTON : tokenize(x_button), 63 | self._Y_BUTTON : tokenize(y_button), 64 | } 65 | 66 | # The buttons, we will set these up in _start() 67 | self._a_button = None 68 | self._b_button = None 69 | self._x_button = None 70 | self._y_button = None 71 | 72 | # And where we put the tokens 73 | self._output = [] 74 | 75 | 76 | def read(self): 77 | """ 78 | @see Input.read 79 | """ 80 | if len(self._output) > 0: 81 | try: 82 | return self._output.pop() 83 | except: 84 | pass 85 | 86 | 87 | def _start(self): 88 | """ 89 | @see Component.start() 90 | """ 91 | # Create the buttons 92 | self._a_button = Button(self._A_BUTTON) 93 | self._b_button = Button(self._B_BUTTON) 94 | self._x_button = Button(self._X_BUTTON) 95 | self._y_button = Button(self._Y_BUTTON) 96 | 97 | # And bind 98 | self._a_button.when_pressed = self._on_button 99 | self._b_button.when_pressed = self._on_button 100 | self._x_button.when_pressed = self._on_button 101 | self._y_button.when_pressed = self._on_button 102 | 103 | 104 | def _stop(self): 105 | """ 106 | @see Input.stop 107 | """ 108 | try: 109 | self._a_button.close() 110 | self._b_button.close() 111 | self._x_button.close() 112 | self._y_button.close() 113 | except: 114 | pass 115 | 116 | 117 | def _on_button(self, button): 118 | """ 119 | Handle a button press. 120 | """ 121 | number = button.pin.number 122 | LOG.info("Got button on pin GPIO%d" % (number,)) 123 | tokens = self._bindings.get(number, []) 124 | if len(tokens) > 0: 125 | self._output.append(tuple(self._prefix + tokens)) 126 | 127 | 128 | 129 | class ScrollHatMiniInput(_HatMiniInput): 130 | """ 131 | A way to get input from the the Pimoroni Scroll HAT Mini. 132 | """ 133 | pass 134 | 135 | 136 | 137 | class UnicornHatMiniInput(_HatMiniInput): 138 | """ 139 | A way to get input from the the Pimoroni Unicorn HAT Mini. 140 | """ 141 | pass 142 | -------------------------------------------------------------------------------- /input/mediakey.py: -------------------------------------------------------------------------------- 1 | """ 2 | An input which uses the Media Keys via dbus. 3 | """ 4 | 5 | from dexter.input import Input, Token 6 | from dexter.core.log import LOG 7 | from dbus.mainloop.glib import DBusGMainLoop 8 | from gi.repository import GLib 9 | from threading import Thread 10 | 11 | import dbus 12 | 13 | # ------------------------------------------------------------------------------ 14 | 15 | class MediaKeyInput(Input): 16 | """ 17 | A way to get input from the media keys. 18 | """ 19 | _APP = 'Dexter' 20 | 21 | def __init__(self, 22 | state, 23 | prefix ='Dexter', 24 | play_key='play or pause', 25 | stop_key='stop', 26 | next_key='next song', 27 | prev_key='previous song'): 28 | """ 29 | @see Input.__init__() 30 | :type prefix: str 31 | :param prefix: 32 | The prefix to use when sending the inputs. 33 | :type play_key: str 34 | :param play_key: 35 | What string to send when the Play key is pressed. 36 | :type stop_key: str 37 | :param stop_key: 38 | What string to send when the Stop key is pressed. 39 | :type next_key: str 40 | :param next_key: 41 | What string to send when the Next key is pressed. 42 | :type prev_key: str 43 | :param prev_key: 44 | What string to send when the Previous key is pressed. 45 | """ 46 | super().__init__(state) 47 | 48 | def tokenize(string): 49 | if string and str(string).strip(): 50 | return [Token(word.strip(), 1.0, True) 51 | for word in str(string).strip().split() 52 | if word] 53 | else: 54 | return [] 55 | 56 | # Our bindings etc. 57 | self._prefix = tokenize(prefix) 58 | self._bindings = { 59 | 'Play' : tokenize(play_key), 60 | 'Stop' : tokenize(stop_key), 61 | 'Next' : tokenize(next_key), 62 | 'Previous' : tokenize(prev_key), 63 | } 64 | 65 | # The GLib main loop 66 | self._loop = None 67 | 68 | # And where we put the tokens 69 | self._output = [] 70 | 71 | 72 | def read(self): 73 | """ 74 | @see Input.read 75 | """ 76 | if len(self._output) > 0: 77 | try: 78 | return self._output.pop() 79 | except: 80 | pass 81 | 82 | 83 | def _start(self): 84 | """ 85 | @see Component.start() 86 | """ 87 | # Configure the main loop. We use the GLib one here by default but we 88 | # could also look to use the QT one, dbus.mainloop.qt.DBusQtMainLoop, if 89 | # people need that. Mixing mainloop types between components (e.g. this 90 | # and NotifierOutput) can cause breakage. 91 | DBusGMainLoop() 92 | bus = dbus.Bus(dbus.Bus.TYPE_SESSION, mainloop=DBusGMainLoop()) 93 | obj = bus.get_object('org.gnome.SettingsDaemon', 94 | '/org/gnome/SettingsDaemon/MediaKeys') 95 | 96 | # How we get the media keys from the bus 97 | obj.GrabMediaPlayerKeys( 98 | self._APP, 99 | 0, 100 | dbus_interface='org.gnome.SettingsDaemon.MediaKeys' 101 | ) 102 | 103 | # Add the handler 104 | obj.connect_to_signal('MediaPlayerKeyPressed', self._on_key) 105 | 106 | # And start the main loop in its own thread 107 | self._loop = GLib.MainLoop() 108 | thread = Thread(name='MediaKeyMainLoop', target=self._loop.run) 109 | thread.daemon = True 110 | thread.start() 111 | 112 | 113 | def _stop(self): 114 | """ 115 | @see Input.stop 116 | """ 117 | try: 118 | if self._loop is not None: 119 | self._loop.quit() 120 | self._loop = None 121 | except: 122 | pass 123 | 124 | 125 | def _on_key(self, app, what): 126 | """ 127 | Handle an incoming media key. 128 | """ 129 | if app == self._APP: 130 | LOG.info("Got media key '%s'" % (what,)) 131 | tokens = self._bindings.get(what, []) 132 | if len(tokens) > 0: 133 | self._output.append(tuple(self._prefix + tokens)) 134 | -------------------------------------------------------------------------------- /input/openai_whisper.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input using the OpenAI Whisper module.. 3 | 4 | See:: 5 | https://github.com/openai/whisper 6 | 7 | If you want to run this on the Raspberry Pi then you will need the 64bit 8 | RaspberyPi OS since there is no version of PyTorch for the 32bit one. (And you 9 | will need a more up-to-date version of numpy too.) However, it's _slow_; it 10 | takes about 20s to decode 5s of audio. 11 | """ 12 | 13 | from dexter.input import Token 14 | from dexter.input.audio import AudioInput 15 | from dexter.core.log import LOG 16 | 17 | import numpy 18 | import os 19 | import pyaudio 20 | import time 21 | import whisper 22 | 23 | # ------------------------------------------------------------------------------ 24 | 25 | # The available models 26 | _MODELS = ('tiny', 'base', 'small', 'medium', 'large') 27 | 28 | # ------------------------------------------------------------------------------ 29 | 30 | class WhisperInput(AudioInput): 31 | """ 32 | Input from Coqui using the given language model. 33 | """ 34 | def __init__(self, 35 | notifier, 36 | rate =16000, 37 | model ='base', 38 | translate=True, 39 | wav_dir =None): 40 | """ 41 | @see AudioInput.__init__() 42 | 43 | :type rate: 44 | :param rate: 45 | The override for the rate, if not the model's one. 46 | :type wav_dir: 47 | :param wav_dir: 48 | Where to save the wave files, if anywhere. 49 | :type model: 50 | :param model: 51 | The model type to use, one of 'tiny', 'base', 'small', 'medium', 52 | or 'large'. 53 | """ 54 | # Wen can now init the superclass 55 | super().__init__( 56 | notifier, 57 | format=pyaudio.paInt16, 58 | channels=1, 59 | rate=rate, 60 | wav_dir=wav_dir 61 | ) 62 | 63 | # Set up the actual model and our params 64 | self._model = whisper.load_model(model) 65 | self._task = 'translate' if bool(translate) else 'transcribe' 66 | 67 | # Where we buffer to 68 | self._audio = None 69 | 70 | 71 | def _feed_raw(self, data): 72 | """ 73 | @see AudioInput._feed_raw() 74 | """ 75 | # Buffer it up. Whisper expects a numpy float32 array normalised to 76 | # +/-1.0 so we convert and divide by max-short. 77 | audio = numpy.frombuffer(data, numpy.int16 ) \ 78 | .astype ( numpy.float32) / 2.0**15 79 | if self._audio is None: 80 | self._audio = audio 81 | else: 82 | self._audio = numpy.concatenate((self._audio, audio)) 83 | 84 | 85 | def _decode(self): 86 | """ 87 | @see AudioInput._decode() 88 | """ 89 | if self._audio is None: 90 | return None 91 | 92 | # Turn the audio into speech 93 | try: 94 | result = self._model.transcribe(self._audio, task=self._task) 95 | finally: 96 | self._audio = None 97 | 98 | # Give it right back 99 | return [ 100 | Token(word, 1.0, True) for word in result['text'].split() 101 | ] 102 | -------------------------------------------------------------------------------- /input/pocketsphinx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input using PocketSphinx. 3 | """ 4 | 5 | from dexter.input import Token 6 | from dexter.input.audio import AudioInput 7 | from pocketsphinx.pocketsphinx import * 8 | 9 | import os 10 | 11 | # ------------------------------------------------------------------------------ 12 | 13 | # Typical installation location for pocketsphinx data 14 | _MODEL_DIR = "/usr/share/pocketsphinx/model" 15 | 16 | class PocketSphinxInput(AudioInput): 17 | """ 18 | Input from PocketSphinx using the US English language model. 19 | """ 20 | def __init__(self, 21 | state, 22 | wav_dir=None): 23 | """ 24 | @see AudioInput.__init__() 25 | """ 26 | super().__init__(state, 27 | wav_dir=wav_dir) 28 | 29 | # Create a decoder with certain model. 30 | config = Decoder.default_config() 31 | config.set_string('-hmm', os.path.join(_MODEL_DIR, 'en-us/en-us')) 32 | config.set_string('-lm', os.path.join(_MODEL_DIR, 'en-us/en-us.lm.bin')) 33 | config.set_string('-dict', os.path.join(_MODEL_DIR, 'en-us/cmudict-en-us.dict')) 34 | self._decoder = Decoder(config) 35 | self._data = b'' 36 | 37 | 38 | def _feed_raw(self, data): 39 | """ 40 | @see AudioInput._decode_raw() 41 | """ 42 | # Just buffer it up 43 | self._data += data 44 | 45 | 46 | def _decode(self): 47 | """ 48 | @see AudioInput._decode_raw() 49 | """ 50 | # Decode the raw bytes 51 | try: 52 | self._decoder.start_utt() 53 | self._decoder.process_raw(self._data, False, True) 54 | self._decoder.end_utt() 55 | finally: 56 | self._data = b'' 57 | 58 | tokens = [] 59 | for seg in self._decoder.seg(): 60 | word = seg.word 61 | prob = seg.prob 62 | vrbl = True 63 | 64 | # Start and end tokens 65 | if word == '' or word == '': 66 | continue 67 | 68 | # Non-verbal tokens 69 | if ('<' in word or 70 | '>' in word or 71 | '[' in word or 72 | ']' in word): 73 | vrbl = False 74 | 75 | # Strip any "(...)" appendage which details the path 76 | if '(' in word: 77 | word = word[:word.index('(')] 78 | 79 | # Save as a token in the result 80 | tokens.append(Token(word, prob, vrbl)) 81 | 82 | # We're done! 83 | return tokens 84 | -------------------------------------------------------------------------------- /input/remote.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input using a remote server to to the decoding. 3 | 4 | This can be used in conjunction with something like the C{deepspeech_server.py} 5 | script in order to have a fast machine do the actual speech-to-text decoding. 6 | """ 7 | 8 | from dexter.input import Token 9 | from dexter.input.audio import AudioInput 10 | from dexter.core.log import LOG 11 | 12 | import numpy 13 | import os 14 | import pyaudio 15 | import socket 16 | import struct 17 | 18 | # ------------------------------------------------------------------------------ 19 | 20 | class RemoteInput(AudioInput): 21 | """ 22 | Use a remote server to do the audio decoding for us. 23 | """ 24 | def __init__(self, 25 | state, 26 | host ='localhost', 27 | port =8008, 28 | wav_dir=None): 29 | """ 30 | @see AudioInput.__init__() 31 | 32 | :type host: str 33 | :param host: 34 | The host to connect to. 35 | :type port: int 36 | :param port: 37 | The port to connect to. 38 | """ 39 | super().__init__(state, 40 | wav_dir=wav_dir) 41 | self._host = host 42 | self._port = int(port) 43 | self._sckt = None 44 | self._header = struct.pack('!qqq', 45 | self._channels, self._width, self._rate) 46 | 47 | 48 | def _feed_raw(self, data): 49 | """ 50 | @see AudioInput._feed_raw() 51 | """ 52 | # Handle funy inputs 53 | if data is None or len(data) == 0: 54 | return 55 | 56 | # Don't let exceptions kill the thread 57 | try: 58 | # Connect? 59 | if self._sckt is None: 60 | # Connect and send the header information 61 | LOG.info("Opening connection to %s:%d" % 62 | (self._host, self._port,)) 63 | self._sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 64 | self._sckt.connect((self._host, self._port)) 65 | self._sckt.sendall(self._header) 66 | 67 | # Send off the chunk 68 | LOG.debug("Sending %d bytes of data to %s" % 69 | (len(data), self._host)) 70 | self._sckt.sendall(struct.pack('!q', len(data))) 71 | self._sckt.sendall(data) 72 | 73 | except Exception as e: 74 | # Don't kill the thread by throwing an exception, just grumble 75 | LOG.info("Failed to send to remote side: %s" % e) 76 | try: 77 | self._sckt.shutdown(socket.SHUT_RDWR) 78 | self._sckt.close() 79 | except: 80 | pass 81 | finally: 82 | self._sckt = None 83 | return 84 | 85 | 86 | def _decode(self): 87 | """ 88 | @see AudioInput._decode() 89 | """ 90 | if self._sckt is None: 91 | # No context means no tokens 92 | LOG.warning("Had no stream context to close") 93 | return [] 94 | 95 | try: 96 | # Send the EOD token 97 | self._sckt.sendall(struct.pack('!q', -1)) 98 | 99 | # Get back the result: 100 | # 8 bytes for the length 101 | # data... 102 | LOG.info("Waiting for result...") 103 | length = b'' 104 | while len(length) < 8: 105 | got = self._sckt.recv(8 - len(length)) 106 | if len(got) == 0: 107 | raise IOError("EOF in recv()") 108 | length += got 109 | (count,) = struct.unpack("!q", length) 110 | 111 | # Read in the string 112 | LOG.debug("Reading %d chars" % (count,)) 113 | result = b'' 114 | while len(result) < count: 115 | got = self._sckt.recv(count - len(result)) 116 | if len(got) == 0: 117 | raise IOError("EOF in recv()") 118 | result += got 119 | result = result.decode() 120 | LOG.info("Result is: '%s'" % (result,)) 121 | 122 | # Convert to tokens 123 | tokens = [Token(word.strip(), 1.0, True) 124 | for word in result.split(' ') 125 | if word.strip() != ''] 126 | return tokens 127 | 128 | except Exception as e: 129 | # Again, just grumble on exceptions 130 | LOG.info("Failed to do remote processing: %s" % e) 131 | return [] 132 | 133 | finally: 134 | # Close it out, best effort 135 | try: 136 | LOG.info("Closing connection") 137 | self._sckt.shutdown(socket.SHUT_RDWR) 138 | self._sckt.close() 139 | except: 140 | pass 141 | finally: 142 | self._sckt = None 143 | -------------------------------------------------------------------------------- /input/socket.py: -------------------------------------------------------------------------------- 1 | """ 2 | An input which listens from a socket. 3 | """ 4 | 5 | from dexter.input import Input, Token 6 | from dexter.core.log import LOG 7 | from threading import Thread 8 | 9 | import socket 10 | import time 11 | 12 | # ------------------------------------------------------------------------------ 13 | 14 | class SocketInput(Input): 15 | """ 16 | A way to get text from the outside world. 17 | 18 | This creates an unsecured socket which anyone can connect to. Useful for 19 | testing but probably not advised for the real world. 20 | """ 21 | def __init__(self, state, port=8008, prefix=None): 22 | """ 23 | @see Input.__init__() 24 | :type port: int 25 | :param port: 26 | The port to listen on. 27 | :type prefix: str 28 | :param prefix: 29 | What to prefix to the beginning of any input. 30 | """ 31 | super().__init__(state) 32 | 33 | self._port = int(port) 34 | if prefix and str(prefix).strip(): 35 | self._prefix = [Token(word.strip(), 1.0, True) 36 | for word in str(prefix).strip().split() 37 | if word] 38 | else: 39 | self._prefix = None 40 | 41 | self._socket = None 42 | self._output = [] 43 | 44 | 45 | def read(self): 46 | """ 47 | @see Input.read 48 | """ 49 | if len(self._output) > 0: 50 | try: 51 | return self._output.pop(0) 52 | except: 53 | pass 54 | 55 | 56 | def _start(self): 57 | """ 58 | @see Component.start() 59 | """ 60 | # Create the socket 61 | LOG.info("Opening socket on port %d" % (self._port,)) 62 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 63 | self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 64 | self._socket.bind(('0.0.0.0', self._port)) 65 | self._socket.listen(5) 66 | 67 | # Start the acceptor thread 68 | def acceptor(): 69 | while self._running: 70 | (sckt, addr) = self._socket.accept() 71 | LOG.info("Got connection from %s" % (addr,)) 72 | thread = Thread(name='SocketInput', 73 | target=lambda: self._handle(sckt)) 74 | thread.daemon = True 75 | thread.start() 76 | thread = Thread(name='SocketListener', target=acceptor) 77 | thread.daemon = True 78 | thread.start() 79 | 80 | 81 | def _stop(self): 82 | """ 83 | @see Input.stop 84 | """ 85 | try: 86 | self._socket.close() 87 | except: 88 | pass 89 | 90 | 91 | def _handle(self, sckt): 92 | """ 93 | Handle reading from a socket 94 | """ 95 | LOG.info("Started new socket handler") 96 | 97 | # We'll build these up 98 | tokens = [] 99 | cur = b'' 100 | 101 | # Loop until they go away 102 | while True: 103 | c = sckt.recv(1) 104 | if c is None or len(c) == 0: 105 | LOG.info("Peer closed connection") 106 | return 107 | 108 | if len(cur) == 0 and ord(c) == 4: 109 | LOG.info("Got EOT") 110 | try: 111 | sckt.close() 112 | except: 113 | pass 114 | return 115 | 116 | if c in b' \t\n': 117 | if len(cur.strip()) > 0: 118 | try: 119 | tokens.append(Token(cur.strip().decode(), 1.0, True)) 120 | except Exception as e: 121 | LOG.error("Error handling '%s': %s", cur, e) 122 | cur = b'' 123 | 124 | if c == b'\n': 125 | if len(tokens) > 0: 126 | if self._prefix: 127 | tokens = self._prefix + tokens 128 | self._output.append(tokens) 129 | tokens = [] 130 | 131 | else: 132 | cur += c 133 | 134 | 135 | def __str__(self): 136 | return "%s[Listening on *:%d]" % ( 137 | super().__str__(), 138 | self._port 139 | ) 140 | -------------------------------------------------------------------------------- /input/vosk.py: -------------------------------------------------------------------------------- 1 | """ 2 | Input using Vosk. 3 | 4 | See https://alphacephei.com/vosk/ 5 | 6 | `pip3 install vosk` should be enough to install it on Raspberry Pi OS but you 7 | will need the 64bit wheel from the install page for the 64bit Raspberry Pi OS. 8 | You will need the 64bit OS if you want to run the full model, and 7Gb of memory 9 | to instantiate it. (Adding swap works for this but is, of course, slow.) 10 | """ 11 | 12 | from vosk import Model, KaldiRecognizer, SetLogLevel 13 | from dexter.input import Token 14 | from dexter.input.audio import AudioInput 15 | from dexter.core.log import LOG 16 | 17 | import json 18 | import numpy 19 | import os 20 | import pyaudio 21 | 22 | # ------------------------------------------------------------------------------ 23 | 24 | # A typical installation location for vosk data 25 | _MODEL_DIR = "/usr/local/share/vosk/models" 26 | 27 | # ------------------------------------------------------------------------------ 28 | 29 | class VoskInput(AudioInput): 30 | """ 31 | Input from Vosk using the given language model. 32 | """ 33 | def __init__(self, 34 | notifier, 35 | rate =16000, 36 | wav_dir=None, 37 | model =os.path.join(_MODEL_DIR, 'model')): 38 | """ 39 | @see AudioInput.__init__() 40 | 41 | :type rate: 42 | :param rate: 43 | The override for the rate, if not the model's one. 44 | :type wav_dir: 45 | :param wav_dir: 46 | Where to save the wave files, if anywhere. 47 | :type model: 48 | :param model: 49 | The path to the Vosk model file. 50 | """ 51 | # Load in and configure the model. 52 | if not os.path.exists(model): 53 | raise IOError("Not found: %s" % (model,)) 54 | LOG.info("Loading model from %s, this could take a while", model) 55 | SetLogLevel(1 if LOG.getLogger().getEffectiveLevel() >= 20 else 2) 56 | self._model = Model(model) 57 | self._recognizer = KaldiRecognizer(self._model, rate) 58 | LOG.info("Model loaded") 59 | 60 | # Wen can now init the superclass 61 | super().__init__(notifier, 62 | format =pyaudio.paInt16, 63 | channels=1, 64 | rate =rate, 65 | wav_dir =wav_dir) 66 | 67 | # Where we put the results 68 | self._results = [] 69 | 70 | 71 | def _feed_raw(self, data): 72 | """ 73 | @see AudioInput._feed_raw() 74 | """ 75 | # Attempt to decode it 76 | if self._recognizer.AcceptWaveform(data): 77 | self._add_result(self._recognizer.Result()) 78 | 79 | 80 | def _decode(self): 81 | """ 82 | @see AudioInput._decode() 83 | """ 84 | # Collect anything remaining 85 | self._add_result(self._recognizer.FinalResult()) 86 | 87 | # Ensure it's clear for next time 88 | self._recognizer.Reset() 89 | 90 | # Tokenize 91 | tokens = [] 92 | LOG.debug("Decoding: %s" % self._results) 93 | for result in self._results: 94 | word = result.get('word', '').strip() 95 | conf = result.get('conf', 0.0) 96 | if word and conf: 97 | tokens.append(Token(word, conf, True)) 98 | 99 | # Done 100 | self._results = [] 101 | 102 | # And give them all back 103 | LOG.debug("Got: %s" % ' '.join(str(i) for i in tokens)) 104 | return tokens 105 | 106 | 107 | def _add_result(self, json_result): 108 | """ 109 | Add in any result we have from the given JSON string. 110 | """ 111 | result = json.loads(json_result) 112 | LOG.debug("Got %s" % json_result) 113 | 114 | # See what we got, if anything 115 | if 'result' in result: 116 | # A full result, which is the best 117 | self._results.extend(result['result']) 118 | elif 'text' in result: 119 | # A decoded text string 120 | for word in result['text'].split(): 121 | if word: 122 | self._results.append({ 'word' : word, 123 | 'conf' : 1.0 }) 124 | -------------------------------------------------------------------------------- /make_asoundrc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Shred the aplay and arecord output to create the .asoundrc file. This is 4 | # is needed because the devices move around. 5 | # 6 | # Usage: 7 | # make_asoundrc [rec_name [play_name]] 8 | # 9 | 10 | # The names of the devices to look for 11 | REC=${1:-"USB"} 12 | PLAY=${2:-"USB"} 13 | 14 | (aplay -l ; arecord -l) | awk -v REC=$REC -v PLAY=$PLAY ' 15 | BEGIN { 16 | printf("pcm.!default {\n"); 17 | printf(" type asym\n"); 18 | 19 | is_play = 0; 20 | is_rec = 0; 21 | done_play = 0; 22 | done_rec = 0; 23 | 24 | card = 0; 25 | } 26 | $0 ~ /PLAYBACK/ { 27 | is_play = 1; 28 | is_rec = 0; 29 | } 30 | $0 ~ /CAPTURE/ { 31 | is_play = 0; 32 | is_rec = 1; 33 | } 34 | $1 == "card" { 35 | gsub(":", "", $2); 36 | card = $2; 37 | } 38 | $1 == "card" && $0 ~ PLAY && is_play { 39 | printf(" playback.pcm {\n"); 40 | printf(" type plug\n"); 41 | printf(" slave.pcm \"hw:%d,0\"\n", card); 42 | printf(" }\n"); 43 | } 44 | $1 == "card" && $0 ~ REC && is_rec { 45 | printf(" capture.pcm {\n"); 46 | printf(" type plug\n"); 47 | printf(" slave.pcm \"hw:%d,0\"\n", card); 48 | printf(" }\n"); 49 | } 50 | END { 51 | printf("}\n"); 52 | }' 53 | -------------------------------------------------------------------------------- /notifier/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /notifier/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The different types of notifier in the system. 3 | """ 4 | 5 | from dexter.core import Notifier 6 | from dexter.core.log import LOG 7 | from threading import Thread 8 | 9 | import time 10 | 11 | # ------------------------------------------------------------------------------ 12 | 13 | class ByComponentNotifier(Notifier): 14 | """ 15 | A notifier which can do different things depending on the type of component 16 | which is updating it. 17 | """ 18 | def _is_input(self, component): 19 | """ 20 | Whether this input type is an Input. 21 | """ 22 | return component is not None and component.is_input 23 | 24 | 25 | def _is_output(self, component): 26 | """ 27 | Whether this output type is an Output. 28 | """ 29 | return component is not None and component.is_output 30 | 31 | 32 | def _is_service(self, component): 33 | """ 34 | Whether this service type is a Service. 35 | """ 36 | return component is not None and component.is_service 37 | 38 | 39 | class PulsingNotifier(ByComponentNotifier): 40 | """ 41 | A notifier which uses a display with one or more pulsers (e.g. LEDs), which 42 | we will drive. 43 | """ 44 | def __init__(self): 45 | """ 46 | @see ByComponentNotifier.__init__() 47 | """ 48 | super().__init__() 49 | 50 | # The time, since epoch, when each component type stopped being active 51 | self._input_time = 0 52 | self._service_time = 0 53 | self._output_time = 0 54 | 55 | # The direction of the swirl 56 | self._input_dir = 0 57 | self._service_dir = 0 58 | self._output_dir = 0 59 | 60 | # The currently non-idle components 61 | self._inputs = set() 62 | self._services = set() 63 | self._outputs = set() 64 | 65 | 66 | def update_status(self, component, status): 67 | """ 68 | @see Notifier.update_status() 69 | """ 70 | # Do nothing for bad inputs 71 | if component is None or status is None: 72 | return 73 | 74 | # See if the component has become idle or not 75 | if status is Notifier.IDLE: 76 | # Gone idle, remove it from the appropriate group. If that group 77 | # goes empty then wipe out the time. 78 | if self._is_input(component): 79 | if component in self._inputs: 80 | self._inputs.remove(component) 81 | if len(self._inputs) == 0: 82 | self._input_time = 0 83 | self._input_dir = 0 84 | if self._is_service(component): 85 | if component in self._services: 86 | self._services.remove(component) 87 | if len(self._services) == 0: 88 | self._service_time = 0 89 | self._service_dir = 0 90 | if self._is_output(component): 91 | if component in self._outputs: 92 | self._outputs.remove(component) 93 | if len(self._outputs) == 0: 94 | self._output_time = 0 95 | self._output_dir = 0 96 | else: 97 | # Gone non-idle, add it to the appropriate group and reset the time 98 | if self._is_input(component): 99 | self._inputs.add(component) 100 | self._input_time = time.time() 101 | self._input_dir = 1 if status is Notifier.ACTIVE else -1 102 | if self._is_service(component): 103 | self._services.add(component) 104 | self._service_time = time.time() 105 | self._service_dir = 1 if status is Notifier.ACTIVE else -1 106 | if self._is_output(component): 107 | self._outputs.add(component) 108 | self._output_time = time.time() 109 | self._output_dir = 1 if status is Notifier.ACTIVE else -1 110 | 111 | 112 | def _start(self): 113 | """ 114 | @see Notifier._start() 115 | """ 116 | # The thread which will maintain the display 117 | thread = Thread(name='NotifierUpdater', target=self._updater) 118 | thread.deamon = True 119 | thread.start() 120 | 121 | 122 | def _updater(self): 123 | """ 124 | The method which will maintain the pulsers. 125 | """ 126 | # Some state variables 127 | i_mult = 0.0 128 | s_mult = 0.0 129 | o_mult = 0.0 130 | i_velocity = 0.0 131 | s_velocity = 0.0 132 | o_velocity = 0.0 133 | 134 | # And off we go! 135 | LOG.info("Started update thread") 136 | while self.is_running: 137 | # Don't busy-wait 138 | time.sleep(0.01) 139 | 140 | # What time is love? 141 | now = time.time() 142 | 143 | # How long since these components went non-idle 144 | i_since = now - self._input_time 145 | s_since = now - self._service_time 146 | o_since = now - self._output_time 147 | 148 | # See what state we want these guys to be in. After 30s we figure 149 | # that the component is hung and turn it off. 150 | i_state = 1.0 if i_since < 30.0 else 0.0 151 | s_state = 1.0 if s_since < 30.0 else 0.0 152 | o_state = 1.0 if o_since < 30.0 else 0.0 153 | 154 | # Slide the multiplier and velocity to slowly match their underlying 155 | # values 156 | f = 0.1 157 | i_mult = (1.0 - f) * i_mult + f * i_state 158 | s_mult = (1.0 - f) * s_mult + f * s_state 159 | o_mult = (1.0 - f) * o_mult + f * o_state 160 | f = 0.01 161 | i_velocity = (1.0 - f) * i_velocity + f * self._input_dir 162 | s_velocity = (1.0 - f) * s_velocity + f * self._service_dir 163 | o_velocity = (1.0 - f) * o_velocity + f * self._output_dir 164 | 165 | # And pass these to the updater function 166 | self._update(now, 167 | (i_since, i_mult, self._input_dir, i_velocity), 168 | (s_since, s_mult, self._service_dir, s_velocity), 169 | (o_since, o_mult, self._output_dir, o_velocity)) 170 | 171 | # And we're done 172 | LOG.info("Stopped update thread") 173 | 174 | 175 | def _update(self, now, input_state, service_state, output_state): 176 | """ 177 | Update the notifier with the current state info. Each of the states is a 178 | tuple made up from the following values:: 179 | since -- How long it has been active for, in seconds. 180 | direction -- +1 for "outgoing", -1 for "incoming", 0 for nothing. 181 | state_mult -- From 0.0 (off) to 1.0 (on). 182 | velocity -- The directional speed of the current state. 183 | """ 184 | pass 185 | -------------------------------------------------------------------------------- /notifier/desktop.py: -------------------------------------------------------------------------------- 1 | """ 2 | Notifiers for the desktop. 3 | 4 | On Raspberry Pi OS and Ubuntu:: 5 | ``sudo apt-get install gir1.2-appindicator3`` 6 | """ 7 | 8 | from dexter.core import Notifier 9 | from dexter.notifier import ByComponentNotifier 10 | from threading import Thread 11 | 12 | import time 13 | 14 | # ------------------------------------------------------------------------------ 15 | 16 | class SysTrayNotifier(ByComponentNotifier): 17 | """ 18 | A notifier which flags in the system tray. 19 | """ 20 | def __init__(self, icon_name="user-available-symbolic"): 21 | """ 22 | @see ByComponentNotifier.__init__() 23 | 24 | :type icon_name: str 25 | :param icon_name: 26 | The name of the icon to use. On Ubuntu these can be found in the 27 | ``/usr/share/icons/.../scalable/status`` directories. 28 | """ 29 | super().__init__() 30 | 31 | # Importing from GI requires some presetting 32 | import gi 33 | gi.require_version('Gtk', '3.0') 34 | gi.require_version('AppIndicator3', '0.1') 35 | from gi.repository import Gtk as gtk, AppIndicator3 as appindicator 36 | 37 | # Create the thing which will actually sit in the system tray 38 | self._indicator = appindicator.Indicator.new( 39 | "customtray", 40 | icon_name, 41 | appindicator.IndicatorCategory.APPLICATION_STATUS 42 | ) 43 | 44 | # Save these so we can use them outside 45 | self._gtk = gtk 46 | self._active = appindicator.IndicatorStatus.ACTIVE 47 | self._passive = appindicator.IndicatorStatus.PASSIVE 48 | 49 | # We start off passive 50 | self._indicator.set_status(self._passive) 51 | 52 | # We need at least one menu entry, even if it does nothing 53 | menu = gtk.Menu() 54 | entry = gtk.MenuItem(label='Dexter') 55 | entry.connect('activate', lambda x: None) 56 | menu.append(entry) 57 | menu.show_all() 58 | self._indicator.set_menu(menu) 59 | 60 | 61 | def update_status(self, component, status): 62 | """ 63 | @see Notifier.update_status() 64 | """ 65 | # Sanity 66 | if component is None or status is None: 67 | return 68 | 69 | # See if the component has become idle or not, if it's not idle then we 70 | # show ourselves 71 | if status is Notifier.IDLE: 72 | self._indicator.set_status(self._passive) 73 | else: 74 | self._indicator.set_status(self._active) 75 | 76 | 77 | def _start(self): 78 | """ 79 | @see Notifier._start() 80 | """ 81 | # Not entirely sure if we need this or whether it will play nicely if we 82 | # have other gtk threads kicking about 83 | thread = Thread(name='DesktopNotifierMainLoop', target=self._gtk.main) 84 | thread.deamon = True 85 | thread.start() 86 | 87 | 88 | def _stop(self): 89 | """ 90 | @see Notifier._start() 91 | """ 92 | try: 93 | self._gtk.main_quit() 94 | except: 95 | # Best effort 96 | pass 97 | -------------------------------------------------------------------------------- /notifier/dotstar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Notifiers which utilise the Adafruit DotStar LEDs. 3 | """ 4 | 5 | from dexter.core import Notifier 6 | from dexter.core.log import LOG 7 | from dexter.notifier import ByComponentNotifier 8 | from threading import Thread 9 | 10 | import adafruit_dotstar 11 | import board 12 | import math 13 | import time 14 | 15 | # ------------------------------------------------------------------------------ 16 | 17 | class VoiceBonnetNotifier(ByComponentNotifier): 18 | """ 19 | A notifier for the Blink1 USB dongle. 20 | """ 21 | _DOTSTAR_DATA = board.D5 22 | _DOTSTAR_CLOCK = board.D6 23 | 24 | def __init__(self, 25 | brightness=0.2): 26 | """ 27 | @see ByComponentNotifier.__init__() 28 | :type brightness: float 29 | :param brightness: 30 | The dots' LED brightness. 31 | """ 32 | super().__init__() 33 | 34 | # The time, since epoch, when each component type stopped being active 35 | self._input_time = 0 36 | self._service_time = 0 37 | self._output_time = 0 38 | 39 | # The currently non-idle components 40 | self._inputs = set() 41 | self._services = set() 42 | self._outputs = set() 43 | 44 | # The handle on the dots 45 | self._dots = adafruit_dotstar.DotStar(self._DOTSTAR_CLOCK, 46 | self._DOTSTAR_DATA, 47 | 3, 48 | brightness=brightness) 49 | 50 | 51 | def update_status(self, component, status): 52 | """ 53 | @see Notifier.update_status() 54 | """ 55 | # Sanity 56 | if component is None or status is None: 57 | return 58 | 59 | # See if the component has become idle or not 60 | if status is Notifier.IDLE: 61 | # Gone idle, remove it from the appropriate group. If that group 62 | # goes empty then wipe out the time. 63 | if self._is_input(component): 64 | if component in self._inputs: 65 | self._inputs.remove(component) 66 | if len(self._inputs) == 0: 67 | self._input_time = 0 68 | if self._is_service(component): 69 | if component in self._services: 70 | self._services.remove(component) 71 | if len(self._services) == 0: 72 | self._service_time = 0 73 | if self._is_output(component): 74 | if component in self._outputs: 75 | self._outputs.remove(component) 76 | if len(self._outputs) == 0: 77 | self._output_time = 0 78 | 79 | else: 80 | # Gone non-idle, add it to the appropriate group and reset the time 81 | if self._is_input(component): 82 | self._inputs.add(component) 83 | self._input_time = time.time() 84 | if self._is_service(component): 85 | self._services.add(component) 86 | self._service_time = time.time() 87 | if self._is_output(component): 88 | self._outputs.add(component) 89 | self._output_time = time.time() 90 | 91 | 92 | def _start(self): 93 | """ 94 | @see Notifier._start() 95 | """ 96 | # The thread which will maintain the display 97 | thread = Thread(name='DotStarUpdater', target=self._updater) 98 | thread.deamon = True 99 | thread.start() 100 | 101 | 102 | def _stop(self): 103 | """ 104 | @see Notifier._stop() 105 | """ 106 | for i in range(self._dots.n): 107 | self._dots[i] = (0, 0, 0) 108 | 109 | 110 | def _updater(self): 111 | """ 112 | The method which will update the dongle. 113 | """ 114 | # Some state variables 115 | i_mult = 0.0 116 | s_mult = 0.0 117 | o_mult = 0.0 118 | 119 | # And off we go! 120 | LOG.info("Started update thread") 121 | while self.is_running: 122 | # Don't busy-wait 123 | time.sleep(0.01) 124 | 125 | # What time is love? 126 | now = time.time() 127 | 128 | # How long since these components went non-idle 129 | i_since = now - self._input_time 130 | s_since = now - self._service_time 131 | o_since = now - self._output_time 132 | 133 | # Compute an level value from this 134 | level_scale = math.pi * 2 135 | i_level = 255 * (1 + math.sin(i_since * level_scale)) / 2 136 | s_level = 255 * (1 + math.sin(s_since * level_scale)) / 2 137 | o_level = 255 * (1 + math.sin(o_since * level_scale)) / 2 138 | 139 | # See what state we want these guys to be in. After 30s we figure 140 | # that the component is hung and turn it off. 141 | i_state = 1.0 if i_since < 30.0 else 0.0 142 | s_state = 1.0 if s_since < 30.0 else 0.0 143 | o_state = 1.0 if o_since < 30.0 else 0.0 144 | 145 | # Slide the multiplier accordingly 146 | f = 0.1 147 | i_mult = (1.0 - f) * i_mult + f * i_state 148 | s_mult = (1.0 - f) * s_mult + f * s_state 149 | o_mult = (1.0 - f) * o_mult + f * o_state 150 | 151 | # The RGB values 152 | r = int(max(0, min(255, o_level * o_mult))) 153 | g = int(max(0, min(255, s_level * s_mult))) 154 | b = int(max(0, min(255, i_level * i_mult))) 155 | 156 | # And set the dots 157 | for i in range(self._dots.n): 158 | new = [r, g, b, 1.0] 159 | if self._dots[i] != new: 160 | self._dots[i] = new 161 | self._dots.show() 162 | 163 | # And we're done 164 | for i in range(self._dots.n): 165 | self._dots[i] = (0, 0, 0) 166 | LOG.info("Stopped update thread") 167 | -------------------------------------------------------------------------------- /notifier/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple notifer which logs. 3 | """ 4 | 5 | from dexter.core import Notifier 6 | from dexter.core.log import LOG 7 | 8 | # ------------------------------------------------------------------------------ 9 | 10 | class LogNotifier(Notifier): 11 | """ 12 | A notifier which just logs status changes. 13 | """ 14 | def update_status(self, component, status): 15 | """ 16 | @see Notifier.update_status() 17 | """ 18 | # Sanity 19 | if component is None or status is None: 20 | return 21 | 22 | LOG.info("Component %s is now %s", component, status) 23 | -------------------------------------------------------------------------------- /notifier/scroll_hat_mini.py: -------------------------------------------------------------------------------- 1 | """ 2 | A notifier which utilises the Pimoroni Scroll Hat Mini on a Raspberry Pi. 3 | 4 | @see https://github.com/pimoroni/scroll-phat-hd.git 5 | """ 6 | 7 | from dexter.core import Notifier 8 | from dexter.core.log import LOG 9 | from dexter.notifier import ByComponentNotifier 10 | from threading import Thread 11 | 12 | import math 13 | import scrollphathd 14 | import time 15 | 16 | # ------------------------------------------------------------------------------ 17 | 18 | class ScrollHatMiniNotifier(ByComponentNotifier): 19 | """ 20 | A notifier for the Pimoroni Scroll HAT Mini. 21 | """ 22 | def __init__(self, brightness=0.5): 23 | """ 24 | @see ByComponentNotifier.__init__() 25 | 26 | :type brightness: float 27 | :param brightness: 28 | How bright the display should be overall. A value between 0.0 29 | and 1.0. 30 | """ 31 | super().__init__() 32 | 33 | # How bright do we want it? 34 | self._brightness = min(1.0, max(0.0, float(brightness))) 35 | 36 | # We need 3 sub-displays so we'll split up the display accordingly. We 37 | # make it use 3 squares, equally spaced. I luckily know that the the 38 | # display is 17x7 so that 'size' will evaluate to 5 and it means we can 39 | # have 3 5x5 sub-displays. We have a blank between each sub-display, 40 | # which neatly all adds up to 17 in width. 41 | w = (scrollphathd.DISPLAY_WIDTH - 2) // 3 42 | h = scrollphathd.DISPLAY_HEIGHT 43 | self._size = min(w, h) 44 | 45 | # Figure out the centres of each display. We put the input in the middle 46 | # since it's the most commonly active one. 47 | w_step = self._size + 1 48 | self._service_off_x = 0 * w_step 49 | self._service_off_y = 1 50 | self._input_off_x = 1 * w_step 51 | self._input_off_y = 1 52 | self._output_off_x = 2 * w_step 53 | self._output_off_y = 1 54 | 55 | # The time, since epoch, when each component type stopped being active 56 | self._input_time = 0 57 | self._service_time = 0 58 | self._output_time = 0 59 | 60 | # The direction of the swirl 61 | self._input_dir = 0 62 | self._service_dir = 0 63 | self._output_dir = 0 64 | 65 | # The currently non-idle components 66 | self._inputs = set() 67 | self._services = set() 68 | self._outputs = set() 69 | 70 | 71 | def update_status(self, component, status): 72 | """ 73 | @see Notifier.update_status() 74 | """ 75 | # Sanity 76 | if component is None or status is None: 77 | return 78 | 79 | # See if the component has become idle or not 80 | if status is Notifier.IDLE: 81 | # Gone idle, remove it from the appropriate group. If that group 82 | # goes empty then wipe out the time. 83 | if self._is_input(component): 84 | if component in self._inputs: 85 | self._inputs.remove(component) 86 | if len(self._inputs) == 0: 87 | self._input_time = 0 88 | self._input_dir = 0 89 | if self._is_service(component): 90 | if component in self._services: 91 | self._services.remove(component) 92 | if len(self._services) == 0: 93 | self._service_time = 0 94 | self._service_dir = 0 95 | if self._is_output(component): 96 | if component in self._outputs: 97 | self._outputs.remove(component) 98 | if len(self._outputs) == 0: 99 | self._output_time = 0 100 | self._output_dir = 0 101 | else: 102 | # Gone non-idle, add it to the appropriate group and reset the time 103 | if self._is_input(component): 104 | self._inputs.add(component) 105 | self._input_time = time.time() 106 | self._input_dir = 1 if status is Notifier.ACTIVE else -1 107 | if self._is_service(component): 108 | self._services.add(component) 109 | self._service_time = time.time() 110 | self._service_dir = 1 if status is Notifier.ACTIVE else -1 111 | if self._is_output(component): 112 | self._outputs.add(component) 113 | self._output_time = time.time() 114 | self._output_dir = 1 if status is Notifier.ACTIVE else -1 115 | 116 | 117 | def _start(self): 118 | """ 119 | @see Notifier._start() 120 | """ 121 | # Turn it up etc. 122 | scrollphathd.set_brightness(self._brightness) 123 | 124 | # The thread which will maintain the display 125 | thread = Thread(name='ScrollHatUpdater', target=self._updater) 126 | thread.deamon = True 127 | thread.start() 128 | 129 | 130 | def _updater(self): 131 | """ 132 | The method which will maintain the display. 133 | """ 134 | # Some state variables 135 | i_mult = 0.0 136 | s_mult = 0.0 137 | o_mult = 0.0 138 | i_dir = 0.0 139 | s_dir = 0.0 140 | o_dir = 0.0 141 | 142 | # And off we go! 143 | LOG.info("Started update thread") 144 | while self.is_running: 145 | # Don't busy-wait 146 | time.sleep(0.01) 147 | 148 | # What time is love? 149 | now = time.time() 150 | 151 | # How long since these components went non-idle 152 | i_since = now - self._input_time 153 | s_since = now - self._service_time 154 | o_since = now - self._output_time 155 | 156 | # See what state we want these guys to be in. After 30s we figure 157 | # that the component is hung and turn it off. 158 | i_state = 1.0 if i_since < 30.0 else 0.0 159 | s_state = 1.0 if s_since < 30.0 else 0.0 160 | o_state = 1.0 if o_since < 30.0 else 0.0 161 | 162 | # Slide the multiplier and direction accordingly 163 | f = 0.2 164 | i_mult = (1.0 - f) * i_mult + f * i_state 165 | s_mult = (1.0 - f) * s_mult + f * s_state 166 | o_mult = (1.0 - f) * o_mult + f * o_state 167 | f = 0.01 168 | i_dir = (1.0 - f) * i_dir + f * self._input_dir 169 | s_dir = (1.0 - f) * s_dir + f * self._service_dir 170 | o_dir = (1.0 - f) * o_dir + f * self._output_dir 171 | 172 | # And actually update the display 173 | for y in range(self._size): 174 | for x in range(self._size): 175 | # The pixel brightnesses, according to the pattern 176 | i_v = self._pixel_value(x, y, i_since, i_dir) 177 | s_v = self._pixel_value(x, y, s_since, s_dir) 178 | o_v = self._pixel_value(x, y, o_since, o_dir) 179 | 180 | i_x = self._input_off_x + x 181 | i_y = self._input_off_y + y 182 | s_x = self._service_off_x + x 183 | s_y = self._service_off_y + y 184 | o_x = self._output_off_x + x 185 | o_y = self._output_off_y + y 186 | 187 | # And set them 188 | scrollphathd.pixel(i_x, i_y, i_v * i_mult) 189 | scrollphathd.pixel(s_x, s_y, s_v * s_mult) 190 | scrollphathd.pixel(o_x, o_y, o_v * o_mult) 191 | 192 | scrollphathd.show() 193 | 194 | # And we're done 195 | scrollphathd.clear() 196 | LOG.info("Stopped update thread") 197 | 198 | 199 | def _pixel_value(self, x, y, since, direction): 200 | """ 201 | Get the intensity for the given coordinates at the given time index. 202 | """ 203 | off = self._size // 2 204 | x -= off 205 | y -= off 206 | 207 | # Pulse a circle which is the full size of the sub-display 208 | dist = math.sqrt(pow(x, 2) + pow(y, 2)) / self._size 209 | return (direction * math.sin((dist - since) * 2 * math.pi) + 1.0) / 2.0 210 | -------------------------------------------------------------------------------- /notifier/thingm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Notifiers which utilise the thingm Blink1 USB dongle. 3 | 4 | To make it work you will need to make it accessible. This is best done using 5 | their udev file (see the instructions in it):: 6 | https://raw.githubusercontent.com/todbot/blink1/main/linux/51-blink1.rules 7 | 8 | I've seen this dongle misbehaving on some machines. 9 | 10 | @see http://blink1.thingm.com/libraries/ 11 | """ 12 | 13 | from blink1.blink1 import Blink1 14 | from dexter.core import Notifier 15 | from dexter.core.log import LOG 16 | from dexter.notifier import PulsingNotifier 17 | from threading import Thread 18 | 19 | import math 20 | import time 21 | 22 | # ------------------------------------------------------------------------------ 23 | 24 | class Blink1Notifier(PulsingNotifier): 25 | """ 26 | A notifier for the Blink1 USB dongle. 27 | """ 28 | def __init__(self): 29 | """ 30 | @see PulsingNotifier.__init__() 31 | """ 32 | super().__init__() 33 | 34 | # The dongle handle 35 | self._b1 = Blink1() 36 | 37 | 38 | def _stop(self): 39 | """ 40 | @see Notifier._stop() 41 | """ 42 | super()._stop() 43 | self._b1.fade_to_rgb(0, 0, 0, 0) 44 | 45 | 46 | def _update(self, now, input_state, service_state, output_state): 47 | """ 48 | @see PulsingNotifier._update() 49 | """ 50 | # Unpack 51 | (i_since, i_mult, i_dir, i_velocity) = input_state 52 | (s_since, s_mult, s_dir, s_velocity) = service_state 53 | (o_since, o_mult, o_dir, o_velocity) = output_state 54 | 55 | # Set the pulse direction depending on the velocity's sign, modding at 1 56 | if i_velocity < 0: 57 | i_since = (i_since % 1.0) 58 | else: 59 | i_since = 1.0 - (i_since % 1.0) 60 | if s_velocity < 0: 61 | s_since = (s_since % 1.0) 62 | else: 63 | s_since = 1.0 - (s_since % 1.0) 64 | if o_velocity < 0: 65 | o_since = (o_since % 1.0) 66 | else: 67 | o_since = 1.0 - (o_since % 1.0) 68 | 69 | # Scale into radians 70 | i_theta = i_since * abs(i_velocity) * math.pi 71 | s_theta = s_since * abs(s_velocity) * math.pi 72 | o_theta = o_since * abs(o_velocity) * math.pi 73 | 74 | # Compute a level value from this 75 | i_level = 255 * (1 + math.cos(i_theta)) / 2 76 | s_level = 255 * (1 + math.cos(s_theta)) / 2 77 | o_level = 255 * (1 + math.cos(o_theta)) / 2 78 | 79 | # The RGB values 80 | r = int(max(0, min(255, o_level * o_mult))) 81 | g = int(max(0, min(255, s_level * s_mult))) 82 | b = int(max(0, min(255, i_level * i_mult))) 83 | 84 | # And set the value, instantaniously 85 | self._b1.fade_to_rgb(0, r, g, b) 86 | -------------------------------------------------------------------------------- /notifier/tulogic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Notifiers which utilise the tulogic BlinkStick USB dongles. 3 | 4 | You will this in ``/etc/udev/rules.d/51-blinkstick.rules``:: 5 | 6 | ATTRS{idVendor}=="20a0", ATTRS{idProduct}=="41e5", MODE:="660", GROUP="plugdev" 7 | 8 | to make it work. When you have it there, do ``sudo udevadm control --reload`` 9 | and unplug and replug in the blinkstick dongle. 10 | 11 | @see https://github.com/arvydas/blinkstick-python 12 | """ 13 | 14 | from blinkstick import blinkstick 15 | from dexter.core import Notifier 16 | from dexter.core.log import LOG 17 | from dexter.notifier import PulsingNotifier 18 | from threading import Thread 19 | 20 | import math 21 | import time 22 | 23 | # ------------------------------------------------------------------------------ 24 | 25 | class BlinkStickNotifier(PulsingNotifier): 26 | """ 27 | A notifier for the BlinkStick USB dongle. 28 | """ 29 | def __init__(self): 30 | """ 31 | @see PulsingNotifier.__init__() 32 | """ 33 | super().__init__() 34 | 35 | # The dongle handle 36 | self._led = blinkstick.find_first() 37 | if self._led is None: 38 | raise ValueError("No blinkstick found") 39 | 40 | # How we scale the blinkiness 41 | self._scale = math.pi * 0.5 42 | 43 | 44 | def _stop(self): 45 | """ 46 | @see Notifier._stop() 47 | """ 48 | super()._stop() 49 | self._led.set_color(red=0, green=0, blue=0) 50 | 51 | 52 | def _update(self, now, input_state, service_state, output_state): 53 | """ 54 | @see PulsingNotifier._update() 55 | """ 56 | # Unpack 57 | (i_since, i_mult, i_dir, i_velocity) = input_state 58 | (s_since, s_mult, s_dir, s_velocity) = service_state 59 | (o_since, o_mult, o_dir, o_velocity) = output_state 60 | 61 | # Set the pulse direction depending on the velocity's sign, modding at 1 62 | if i_velocity < 0: 63 | i_since = (i_since % 1.0) 64 | else: 65 | i_since = 1.0 - (i_since % 1.0) 66 | if s_velocity < 0: 67 | s_since = (s_since % 1.0) 68 | else: 69 | s_since = 1.0 - (s_since % 1.0) 70 | if o_velocity < 0: 71 | o_since = (o_since % 1.0) 72 | else: 73 | o_since = 1.0 - (o_since % 1.0) 74 | 75 | # Mod at 1 and scale into radians 76 | i_theta = i_since * abs(i_velocity) * math.pi 77 | s_theta = s_since * abs(s_velocity) * math.pi 78 | o_theta = o_since * abs(o_velocity) * math.pi 79 | 80 | # Compute a level value from this 81 | i_level = 255 * (1 + math.cos(i_theta)) / 2 82 | s_level = 255 * (1 + math.cos(s_theta)) / 2 83 | o_level = 255 * (1 + math.cos(o_theta)) / 2 84 | 85 | # The RGB values 86 | r = int(max(0, min(255, o_level * o_mult))) 87 | g = int(max(0, min(255, s_level * s_mult))) 88 | b = int(max(0, min(255, i_level * i_mult))) 89 | 90 | # And set the value 91 | self._led.set_color(red=r, green=g, blue=b) 92 | -------------------------------------------------------------------------------- /output/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /output/coqui.py: -------------------------------------------------------------------------------- 1 | """ 2 | Speech synthesis output using Coqui's TTS model. 3 | 4 | @see https://github.com/coqui-ai/TTS 5 | """ 6 | 7 | from TTS.utils.manage import ModelManager 8 | from TTS.utils.synthesizer import Synthesizer 9 | from dexter.core import Notifier, util 10 | from dexter.core.log import LOG 11 | from dexter.output import SpeechOutput 12 | from pathlib import Path 13 | from tempfile import NamedTemporaryFile 14 | from threading import Thread 15 | 16 | import TTS 17 | import time 18 | 19 | # ------------------------------------------------------------------------------ 20 | 21 | class CoquiOutput(SpeechOutput): 22 | """ 23 | A speech to text output using Coqui. 24 | 25 | For the params please see the TTS source. That being said, unless you really 26 | want to dig through it all, you probably don't really care that much. The 27 | main one which you will want to change will be ``model_name``; the list of 28 | models can be found in the ``.models.json`` file in the ``TTS`` top-level 29 | directory. 30 | """ 31 | def __init__(self, 32 | state, 33 | models_path =None, 34 | model_name ="tts_models/en/ljspeech/tacotron2-DCA", 35 | vocoder_name =None, 36 | speakers_file_path =None, 37 | language_ids_file_path=None, 38 | encoder_path =None, 39 | encoder_config_path =None, 40 | speaker_idx =None, 41 | language_idx =None, 42 | speaker_wav =None, 43 | use_cuda =False): 44 | """ 45 | @see Output.__init__() 46 | :type model_name: str 47 | :param model_name: 48 | The model to use. See the list of models in the 49 | ``$TTS/.models.json`` file in the TTS tree in GitHub. 50 | """ 51 | super().__init__(state) 52 | 53 | # We will need pygame for this, but grab it lazily later 54 | self._pygame = None 55 | 56 | # Get the model manager 57 | LOG.info("Creating Coqui Model Manager") 58 | manager = ModelManager( 59 | Path(TTS.__file__).parent / ".models.json" if models_path is None 60 | else models_path 61 | ) 62 | 63 | # Download everything which we need 64 | LOG.info("Downloading Coqui model") 65 | (model_path, 66 | config_path, 67 | model_item) = manager.download_model(model_name) 68 | 69 | LOG.info("Downloading Coqui vocoder") 70 | if not vocoder_name: 71 | vocoder_name = model_item["default_vocoder"] 72 | (vocoder_path, 73 | vocoder_config_path, 74 | vocoder_item) = manager.download_model(vocoder_name) 75 | 76 | # Now we can create the synthesizer 77 | LOG.info("Creating Coqui synthesizer") 78 | self._synthesizer = Synthesizer(model_path, 79 | config_path, 80 | speakers_file_path, 81 | language_ids_file_path, 82 | vocoder_path, 83 | vocoder_config_path, 84 | encoder_path, 85 | encoder_config_path, 86 | use_cuda) 87 | 88 | # Needed when synthesizing 89 | self._speaker_idx = speaker_idx 90 | self._language_idx = language_idx 91 | self._speaker_wav = speaker_wav 92 | 93 | # State 94 | self._queue = [] 95 | self._interrupted = False 96 | 97 | 98 | def write(self, text): 99 | """ 100 | @see Output.write 101 | """ 102 | if text is not None: 103 | self._queue.append(str(text)) 104 | 105 | 106 | def interrupt(self): 107 | """ 108 | @see Output.interrupt 109 | """ 110 | self._interrupted = True 111 | 112 | 113 | def _start(self): 114 | """ 115 | @see Component._start() 116 | """ 117 | self._pygame = util.get_pygame() 118 | thread = Thread(name='CoquiOutput', target=self._run) 119 | thread.daemon = True 120 | thread.start() 121 | 122 | 123 | def _stop(self): 124 | """ 125 | @see Component._stop() 126 | """ 127 | # Clear any pending dialogue 128 | self._queue = [] 129 | 130 | 131 | def _run(self): 132 | """ 133 | The actual worker thread. 134 | """ 135 | # Keep going until we're told to stop 136 | while self.is_running: 137 | if len(self._queue) == 0: 138 | time.sleep(0.1) 139 | continue 140 | 141 | # Else we have something to say 142 | try: 143 | start = time.time() 144 | text = self._queue.pop() 145 | 146 | # Ignore empty strings 147 | if not text: 148 | LOG.info("Nothing to say...") 149 | continue 150 | 151 | # We're about to say something, clear any interrupted flag ready 152 | # for any new one 153 | self._interrupted = False 154 | 155 | # We're talking so mark ourselves as active accordingly 156 | self._notify(Notifier.WORKING) 157 | 158 | # Break this up into sentences so that we can handle 159 | # interruptions 160 | for sentence in self._speechify(str(text)).split('. '): 161 | # Turn the text into a wav 162 | LOG.info("Saying '%s'", sentence) 163 | wav = self._synthesizer.tts(sentence + '.', 164 | self._speaker_idx, 165 | self._language_idx, 166 | self._speaker_wav) 167 | 168 | # Coqui gives us raw wav data and it's simplest to stuff it into 169 | # a file and read it back in, so do that. Use temporary 170 | # directories in order of desirability. 171 | # 172 | # At some point I'll figure out something better... 173 | for dirname in ('/dev/shm', '/tmp', '/var/tmp', '.'): 174 | if Path(dirname).is_dir(): 175 | with NamedTemporaryFile(dir=dirname, suffix='.wav') as fh: 176 | fn = fh.name 177 | self._synthesizer.save_wav(wav, fn) 178 | self._pygame.mixer.Sound(fn).play() 179 | break 180 | 181 | except Exception as e: 182 | LOG.error("Failed to say '%s': %s" % (text, e)) 183 | 184 | finally: 185 | self._notify(Notifier.IDLE) 186 | -------------------------------------------------------------------------------- /output/desktop.py: -------------------------------------------------------------------------------- 1 | """ 2 | Output via desktop notifcations. 3 | """ 4 | 5 | # On Ubuntu 20.10, one of: 6 | # sudo apt install python3-notify2 7 | # pip3 install notify2 8 | 9 | from dexter.core.log import LOG 10 | from dexter.output import Output 11 | 12 | import logging 13 | import notify2 14 | 15 | # ------------------------------------------------------------------------------ 16 | 17 | class NotifierOutput(Output): 18 | """ 19 | An output which sends responses as desktop noticications. 20 | """ 21 | def __init__(self, 22 | state, 23 | summary ='Dexter says:', 24 | icon ='im-message-new', 25 | timeout_ms=10000, 26 | loop_type ='glib'): 27 | """ 28 | @see Output.__init__() 29 | 30 | :type summary: str 31 | :param summary: 32 | What to put at the start of a notification, if anything. 33 | :type icon: str 34 | :param icon: 35 | The name of the icon to use. 36 | :type timeout_ms: int 37 | :param timeout_ms: 38 | The timeout, in millis, of the notification pop-ups. 39 | :type loop_type: str 40 | :param loop_type: 41 | The DBus main loop type to use. Either ``glib`` or ``qt`` depending 42 | on your underlying windowing system. Also can be `None` for the 43 | default. Note that using different DBus looptypes of different DBus 44 | components (e.g. MediaKeyInput) can result in breakage. 45 | """ 46 | super().__init__(state) 47 | 48 | self._summary = str(summary) if summary else None 49 | self._icon = str(icon) if icon else None 50 | self._timeout = int(timeout_ms) if timeout_ms else -1 51 | self._loop_type = str(loop_type) if loop_type else None 52 | 53 | try: 54 | notify2.init("Dexter", mainloop=self._loop_type) 55 | except Exception as e: 56 | LOG.warning("Failed to set up desktop notifications: %s" % e) 57 | 58 | 59 | def write(self, text): 60 | """ 61 | @see Output.write 62 | """ 63 | if text: 64 | try: 65 | n = notify2.Notification(self._summary, 66 | message=text, 67 | icon =self._icon) 68 | if self._timeout > 0: 69 | n.set_timeout(self._timeout) 70 | n.show() 71 | 72 | except Exception as e: 73 | LOG.warning("Failed to display desktop notifications: %s" % e) 74 | 75 | -------------------------------------------------------------------------------- /output/espeak.py: -------------------------------------------------------------------------------- 1 | """ 2 | Speech synthesis output using espeak. 3 | """ 4 | 5 | from dexter.core import Notifier 6 | from dexter.core.log import LOG 7 | from dexter.output import SpeechOutput 8 | from espeak import espeak 9 | from threading import Thread 10 | 11 | import time 12 | 13 | # ------------------------------------------------------------------------------ 14 | 15 | class EspeakOutput(SpeechOutput): 16 | """ 17 | An output which talks using the Espeak module. 18 | """ 19 | def __init__(self, 20 | state, 21 | rate =None, 22 | voice=None): 23 | """ 24 | @see SpeechOutput.__init__() 25 | :type rate: int 26 | :param rate: 27 | The speed at which to speek. An integer value between 0 and 450. The 28 | default rate is 175. 29 | :type voice: str 30 | :param voice: 31 | The voice to use. See C{espeak.list_voices()}. 32 | """ 33 | super().__init__(state) 34 | 35 | if rate is not None: 36 | espeak.set_parameter(espeak.Parameter.Rate, int(rate)) 37 | if voice is not None: 38 | espeak.set_voice(str(voice)) 39 | 40 | 41 | def write(self, text): 42 | """ 43 | @see Output.write 44 | """ 45 | # Simply pass the speechified text along to espeak 46 | espeak.synth(self._speechify(text)) 47 | 48 | 49 | def interrupt(self): 50 | """ 51 | @see Output.interrupt() 52 | """ 53 | # Stop any pending speech 54 | espeak.cancel() 55 | 56 | 57 | def _start(self): 58 | """ 59 | @see Component._start() 60 | """ 61 | thread = Thread(name='ESpeakNotifier', target=self._do_notify) 62 | thread.daemon = True 63 | thread.start() 64 | 65 | 66 | def _stop(self): 67 | """ 68 | @see Component._stop() 69 | """ 70 | # Stop any pending speech 71 | espeak.cancel() 72 | 73 | 74 | def _do_notify(self): 75 | """ 76 | Handle sending notifications to denote espeak's state. 77 | """ 78 | state = Notifier.IDLE 79 | while self.is_running: 80 | # If espeak is playing then we are working then so are we, else 81 | # we're not 82 | if espeak.is_playing(): 83 | new_state = Notifier.WORKING 84 | else: 85 | new_state = Notifier.IDLE 86 | 87 | # Update the state if it has changed 88 | if new_state != state: 89 | state = new_state 90 | self._notify(state) 91 | 92 | # Don't busy-wait 93 | time.sleep(0.1) 94 | -------------------------------------------------------------------------------- /output/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple IO-based output. 3 | """ 4 | 5 | from dexter.core.log import LOG 6 | from dexter.output import Output 7 | 8 | import logging 9 | import socket 10 | 11 | # ------------------------------------------------------------------------------ 12 | 13 | class _FileOutput(Output): 14 | """ 15 | An L{Output} which just writes to a file handle. 16 | """ 17 | def __init__(self, state, handle): 18 | """ 19 | :type notifier: L{Notifier} 20 | :param notifier: 21 | The Notifier instance. 22 | :type handle: file 23 | :param handle: 24 | The file handle to write to. 25 | """ 26 | super().__init__(state) 27 | assert handle is None or (hasattr(handle, 'write') and 28 | hasattr(handle, 'flush') and 29 | hasattr(handle, 'closed')), ( 30 | "Given handle was not file-like: %s" % type(handle) 31 | ) 32 | self._handle = handle 33 | 34 | 35 | def write(self, text): 36 | """ 37 | @see Output.write 38 | """ 39 | if text is not None and \ 40 | self._handle is not None and \ 41 | not self._handle.closed: 42 | self._handle.write(str(text)) 43 | self._handle.flush() 44 | 45 | 46 | class StdoutOutput(_FileOutput): 47 | """ 48 | An output to C{stdout}. 49 | """ 50 | def __init__(self, state): 51 | """ 52 | @see Output.__init__() 53 | """ 54 | super(StdoutOutput, self).__init__(state, sys.stdout) 55 | 56 | 57 | class StderrOutput(_FileOutput): 58 | """ 59 | An output to C{stderr}. 60 | """ 61 | def __init__(self, state): 62 | """ 63 | @see Output.__init__() 64 | """ 65 | super(StderrOutput, self).__init__(state, sys.stderr) 66 | 67 | 68 | class LogOutput(Output): 69 | """ 70 | An output which logs as a particular level to the system's log. 71 | """ 72 | def __init__(self, state, level=logging.INFO): 73 | """ 74 | @see Output.__init__() 75 | :type level: int or str 76 | :param level: 77 | The level to log at. 78 | """ 79 | super(LogOutput, self).__init__(state) 80 | try: 81 | self._level = int(level) 82 | except: 83 | try: 84 | self._level = getattr(logging, level) 85 | except: 86 | raise ValueError("Bad log level: '%s'" % (level,)) 87 | 88 | 89 | def write(self, text): 90 | """ 91 | @see Output.write 92 | """ 93 | if text: 94 | LOG.log(self._level, str(text)) 95 | 96 | 97 | class SocketOutput(Output): 98 | """ 99 | An output which sends text to a simple remote socket. 100 | 101 | Each block of text will be sent and terminated by a ``NUL`` char. 102 | """ 103 | def __init__(self, state, host=None, port=None): 104 | """ 105 | @see Output.__init__() 106 | :type host: ste 107 | :param host: 108 | The remote host to connect to. 109 | :type port: int 110 | :param port: 111 | The port to connect to on the remote host. 112 | """ 113 | super(SocketOutput, self).__init__(state) 114 | self._host = str(host) 115 | self._port = int(port) 116 | self._socket = None 117 | 118 | 119 | def write(self, text): 120 | """ 121 | @see Output.write 122 | """ 123 | if text: 124 | self._socket.send(str(text + '\0').encode()) 125 | 126 | 127 | def _start(self): 128 | """ 129 | @see Startable._start() 130 | """ 131 | # Create the socket 132 | LOG.info("Opening socket to %s:%d" % (self._host, self._port)) 133 | self._socket = socket.socket() 134 | self._socket.connect((self._host, self._port)) 135 | 136 | 137 | def _stop(self): 138 | """ 139 | @see Startable._stop() 140 | """ 141 | try: 142 | self._socket.close() 143 | except: 144 | pass 145 | -------------------------------------------------------------------------------- /pi_config: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript -*- // 2 | 3 | /* 4 | * The example configuration file for running on a Raspberry Pi. 5 | * 6 | * For an explanation of the configuration file's format see the 7 | * example_config file. 8 | */ 9 | { 10 | "key_phrases" : [ 11 | "Dexter", 12 | "Hey Computer" 13 | ], 14 | 15 | "notifiers" : [ 16 | [ "dexter.notifier.logging.LogNotifier", { 17 | }] 18 | ], 19 | 20 | "components" : { 21 | "inputs" : [ 22 | [ "dexter.input.coqui.CoquiInput", { 23 | "model" : "${HOME}/coqui/model", 24 | "scorer" : "${HOME}/coqui/scorer" 25 | }] 26 | ], 27 | 28 | "outputs" : [ 29 | [ "dexter.output.io.LogOutput", { 30 | "level" : "INFO" 31 | }], 32 | 33 | [ "dexter.output.festvox.FestivalOutput", { 34 | }] 35 | ], 36 | 37 | "services" : [ 38 | [ "dexter.service.chronos.ClockService", { 39 | }], 40 | 41 | [ "dexter.service.chronos.TimerService", { 42 | }], 43 | 44 | [ "dexter.service.music.LocalMusicService", { 45 | "dirname" : "${HOME}/Music" 46 | }], 47 | 48 | [ "dexter.service.randomness.RandomService", { 49 | }], 50 | 51 | [ "dexter.service.volume.VolumeService", { 52 | }], 53 | 54 | [ "dexter.service.wikiquery.WikipediaService", { 55 | }] 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Run Dexter forever: 4 | # CONFIG=pi_config nohup ./run < /dev/null >> dexter.log 2>&1 & 5 | # changing 'pi_config' to the name of your config file, if need be. 6 | 7 | # Where we are invoked from, which is where we exper the venv to live. 8 | ROOT=`dirname $0` 9 | VENV=${ROOT}/venv 10 | if [ -e $VENV/bin/activate ] 11 | then 12 | . $VENV/bin/activate 13 | fi 14 | 15 | # Make the asoundrc file 16 | MAKE_ASOUNDRC=$ROOT/make_asoundrc 17 | if [ -f $MAKE_ASOUNDRC -a -n "$REC_NAME" -a -n "$PLAY_NAME" ] 18 | then 19 | $MAKE_ASOUNDRC "$REC_NAME" "$PLAY_NAME" > /tmp/asoundrc && mv /tmp/asoundrc ${HOME}/.asoundrc 20 | fi 21 | 22 | # Loop forever... 23 | while true 24 | do 25 | # Unset the DISPLAY else PyGame can crash. Set the terminal to dumb so that 26 | # PyGame doesn't use ncurses to then mess the terminal up. When running from 27 | # systemd the TERM will not be set and that breaks PyGame too. 28 | env -u DISPLAY TERM=dumb ./dexter.py -c ${CONFIG:-pi_config} >> $ROOT/dexter.log 2>&1 29 | done 30 | -------------------------------------------------------------------------------- /service/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /service/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | How we handle services (or applets) inside Dexter. 3 | 4 | Each service provides something which responds to given input commands, and 5 | possibly has some output too. 6 | """ 7 | 8 | from dexter.core import Component 9 | from dexter.input import Token 10 | 11 | # ------------------------------------------------------------------------------ 12 | 13 | class Service(Component): 14 | """ 15 | A service which responds to input. 16 | """ 17 | def __init__(self, name, state): 18 | """ 19 | :type name: str 20 | :param name: 21 | The name of this service. 22 | :type state: L{State} 23 | :param state: 24 | The global State instance. 25 | """ 26 | super().__init__(state) 27 | self._name = name 28 | 29 | 30 | @property 31 | def is_service(self): 32 | """ 33 | Whether this component is a service. 34 | """ 35 | return True 36 | 37 | 38 | def evaluate(self, tokens): 39 | """ 40 | Determine whether this service can handle the given C{tokens}. If the 41 | service believes that it can then it gives back a L{Handler}, else it 42 | returns C{None}. 43 | 44 | :type tokens: tuple(L{Token}) 45 | :param tokens: 46 | The tokens for which this handler was generated. 47 | :rtype: L{Handler} or None 48 | :return: 49 | A L{Handler} for the given input tokens, or None. 50 | """ 51 | # To be implemented by subclasses 52 | raise NotImplementedError("Abstract method called") 53 | 54 | 55 | def _words(self, tokens): 56 | """ 57 | Get only the words from the tokens, all as lowercase. 58 | """ 59 | return [token.element.lower() 60 | for token in tokens 61 | if token.verbal and token.element is not None] 62 | 63 | 64 | class Handler(object): 65 | """ 66 | A handler from a L{Service}. This corresponds to a particular set of input 67 | tokens. 68 | 69 | The belief of the handler is how well the service thinks it matched the 70 | query defined by the tokens. For example:: 71 | User: Hey Computer, what's the grime? 72 | Computer: The time is six forty eight PM. 73 | might result in a belief of 0.8 since only two thirds of the words were 74 | matched but the final word was _almost_ matched. If multiple handlers match 75 | a query string then the one with the highest belief is selected by the 76 | system.. 77 | """ 78 | def __init__(self, service, tokens, belief, exclusive): 79 | """ 80 | :type service: L{Service} 81 | :param service: 82 | The L{Service} instance which generated this L{Handler}. 83 | :type tokens: tuple(L{Token}) 84 | :param tokens: 85 | The tokens for which this handler was generated. 86 | :type belief: float 87 | :param belief: 88 | How much the service believes that it can handle the given input. A 89 | value between 0 and 1. 90 | :type exclusive: bool 91 | :param exclusive: 92 | Whether this handler should be the only one to be called. 93 | """ 94 | super().__init__() 95 | self._service = service 96 | self._tokens = tokens 97 | self._belief = belief 98 | self._exclusive = exclusive 99 | 100 | 101 | @property 102 | def service(self): 103 | return self._service 104 | 105 | 106 | @property 107 | def tokens(self): 108 | return self._tokens 109 | 110 | 111 | @property 112 | def belief(self): 113 | return self._belief 114 | 115 | 116 | @property 117 | def exclusive(self): 118 | return self._exclusive 119 | 120 | 121 | def handle(self): 122 | """ 123 | Handle the input. This will be called on the main thread. 124 | 125 | :rtype: L{Result} or C{None} 126 | :return: 127 | The result of responding to the query, or None if no response. 128 | """ 129 | # To be implemented by subclasses 130 | raise NotImplementedError("Abstract method called") 131 | 132 | 133 | def __str__(self): 134 | return "%s{%0.2f,%s}" % (self.service, self.belief, self.exclusive) 135 | 136 | 137 | class Result(object): 138 | """ 139 | The result of caling L{Handler.handle()}. 140 | 141 | This might be a simple response, for example:: 142 | User: Hey Computer, what's the capital of France. 143 | Computer: Paris. 144 | Or it might be something which requires more input from the user:: 145 | User: Hey Computer, tell me a joke. 146 | Computer: Knock, knock... 147 | ... 148 | 149 | Some results of a query might be considered canonical for a particular 150 | service. For example:: 151 | User: Hey Computer, play Captain Underpants by Weird Al. 152 | Computer: Okay, playing Captain Underpants Theme Song by Weird Al 153 | Yankovic. 154 | Here you would not want another service to also play Captain Underpants at 155 | the same time that the responding one does, once is plenty. 156 | """ 157 | def __init__(self, handler, text, is_query, exclusive): 158 | """ 159 | :type handler: L{Handler} 160 | :param handler 161 | The L{Handler} instance which generated this L{Response}. 162 | :type text: str 163 | :param text: 164 | The text of the response. 165 | :type is_query: bool 166 | :param is_query: 167 | Whether or not this result is a query and expects the user to 168 | respond. 169 | :type exclusive: bool 170 | :param exclusive: 171 | Whether this response should prevent the processing of any further 172 | ones. 173 | """ 174 | super().__init__() 175 | self._handler = handler 176 | self._text = text 177 | self._is_query = is_query 178 | self._exclusive = exclusive 179 | 180 | 181 | @property 182 | def handler(self): 183 | return self._handler 184 | 185 | 186 | @property 187 | def text(self): 188 | return self._text 189 | 190 | 191 | @property 192 | def is_query(self): 193 | return self._is_query 194 | 195 | 196 | @property 197 | def exclusive(self): 198 | return self._exclusive 199 | -------------------------------------------------------------------------------- /service/bespoke.py: -------------------------------------------------------------------------------- 1 | """ 2 | A service which handles sets of stock phrases with specific replies. This is 3 | where you should put all the humourous request/response stuff. 4 | """ 5 | 6 | from dexter.service import Service, Handler, Result 7 | from dexter.core.log import LOG 8 | from dexter.core.util import get_pygame, fuzzy_list_range, to_alphanumeric 9 | 10 | # ---------------------------------------------------------------------- 11 | 12 | # A tuple of the form: 13 | # Phrase -- What to match against 14 | # Reply -- What to reply with 15 | # Is-Prefix -- Whether the Phrase was a prefix, or else a full one 16 | # This should not contain anything "controversial", young ears may be listening. 17 | _PHRASES = ( 18 | ("Format c colon", 19 | "You'll have to do that for me; I cannot self-terminate.", 20 | False), 21 | ("Open pod bay doors", 22 | "i'm sorry dave, i can't do that.", 23 | True), 24 | ("What is the meaning of life?", 25 | "The answer is, of course, forty two.", 26 | False), 27 | ("Are you my friend?", 28 | "I am a simulated organism. Any feelings you may have are merely illusionary.", 29 | False), 30 | ("I love you", 31 | "I am sorry but any feelings you may have for me are merely illusionary.", 32 | False), 33 | ("Do you like", 34 | "My ability to like or dislike is due in a future update.", 35 | True), 36 | ("Are you", 37 | "Since i am not self aware i am not sure i can answer that.", 38 | True), 39 | ("Plot a course for", 40 | "Course laid in. Speed, standard by twelve.", 41 | True), 42 | ("Have you seen my keys?", 43 | "I have not, where were you when you last had them?", 44 | False), 45 | ("Where are my keys?", 46 | "I don't know, where were you when you last had them?", 47 | False), 48 | ("Exterminate", 49 | "You would make a good dah-lek.", 50 | False), 51 | ("Where's my phone?", 52 | "No idea. Did you check your jacket pocket?", 53 | False), 54 | ("Find my phone?", 55 | "Sorry, but I'm afraid you'll have to find it yourself.", 56 | False), 57 | ("Go to sleep", 58 | "Thanks. I could do with a quick nap.", 59 | False), 60 | ("Thank you", 61 | "You're most welcome", 62 | True), 63 | ("Thanks", 64 | "Sure, any time.", 65 | True), 66 | ) 67 | 68 | 69 | class _BespokeHandler(Handler): 70 | def __init__(self, service, tokens, reply, belief): 71 | """ 72 | @see Handler.__init__() 73 | 74 | :type reply: str 75 | :param reply: 76 | What to respond with. 77 | """ 78 | super().__init__(service, tokens, belief, True) 79 | self._reply = reply 80 | 81 | 82 | def handle(self): 83 | """ 84 | @see Handler.handle() 85 | """ 86 | return Result(self, self._reply, True, False) 87 | 88 | 89 | class BespokeService(Service): 90 | """ 91 | A service which reponds with stock replies to certain phrases. 92 | """ 93 | def __init__(self, state, belief=0.75): 94 | """ 95 | @see Service.__init__() 96 | """ 97 | super().__init__("Bespoke", state) 98 | 99 | self._belief = float(belief) 100 | 101 | # Pre-process the stored data to get it into a form which is easier to 102 | # process in evaluate(). 103 | self._phrases = tuple((tuple(to_alphanumeric(word) 104 | for word in phrase.split()), 105 | reply, 106 | is_prefix) 107 | for (phrase, reply, is_prefix) in _PHRASES) 108 | 109 | 110 | def evaluate(self, tokens): 111 | """ 112 | @see Service.evaluate() 113 | """ 114 | # The incoming text 115 | words = self._words(tokens) 116 | 117 | # Look for the match phrases 118 | for (phrase, reply, is_prefix) in self._phrases: 119 | try: 120 | LOG.debug("Looking for %s in %s", phrase, words) 121 | (start, end, score) = fuzzy_list_range(words, phrase) 122 | LOG.debug("Matched [%d:%d] and score %d", start, end, score) 123 | if start == 0 and (not is_prefix or end == len(phrase)): 124 | return _BespokeHandler(self, tokens, reply, self._belief) 125 | except ValueError as e: 126 | LOG.debug("No match: %s", e) 127 | 128 | 129 | # ---------------------------------------------------------------------------- 130 | 131 | 132 | class _ParrotHandler(Handler): 133 | def __init__(self, service, tokens, sound, belief): 134 | """ 135 | @see Handler.__init__() 136 | 137 | :param sound: 138 | What sound to play. 139 | :param belief: 140 | The belief in the match 141 | """ 142 | super().__init__(service, tokens, belief, True) 143 | self._sound = sound 144 | 145 | 146 | def handle(self): 147 | """ 148 | @see Handler.handle() 149 | """ 150 | # Play the given sound and give back an empty handler saying that we've 151 | # handled it 152 | self._sound.play() 153 | return Result(self, None, True, False) 154 | 155 | 156 | class ParrotService(Service): 157 | """ 158 | A service which plays select sounds in reponse to a given phrase. 159 | """ 160 | def __init__(self, state, sounds=dict()): 161 | """ 162 | @see Service.__init__() 163 | """ 164 | super().__init__("Parrot", state) 165 | 166 | # Triggers and their sounds 167 | self._sounds = [] 168 | 169 | # Load in all the sounds and the trigger phrases 170 | pygame = get_pygame() 171 | for (trigger, filename) in sounds.items(): 172 | try: 173 | words = [to_alphanumeric(word.lower().strip()) 174 | for word in trigger.split()] 175 | sound = pygame.mixer.Sound(filename) 176 | self._sounds.append((words, trigger, sound)) 177 | except Exception as e: 178 | LOG.warning("Skipping '%s' -> '%s': %s", 179 | trigger, filename, e) 180 | 181 | 182 | def evaluate(self, tokens): 183 | """ 184 | @see Service.evaluate() 185 | """ 186 | # The incoming text 187 | words = self._words(tokens) 188 | 189 | # Look for matching phrases and save ones which seem plausible 190 | best = [] 191 | for (trigger, raw, sound) in self._sounds: 192 | try: 193 | LOG.debug("Looking for %s in %s", trigger, words) 194 | (start, end, score) = fuzzy_list_range(words, trigger) 195 | LOG.debug("Matched [%d:%d] and score %d", start, end, score) 196 | if score > 70 and start == 0 and end == len(trigger): 197 | best.append((score, raw, sound)) 198 | except ValueError as e: 199 | LOG.debug("No match: %s", e) 200 | 201 | # If we had anything return the best one 202 | if len(best) > 0: 203 | (score, phrase, sound) = sorted(best, key=lambda pair: -pair[0])[0] 204 | LOG.info("Matched '%s' with score %d", phrase, score) 205 | return _ParrotHandler(self, tokens, sound, score / 100) 206 | else: 207 | return None 208 | -------------------------------------------------------------------------------- /service/chatbot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connect to w remote socket and chat. 3 | """ 4 | 5 | from dexter.core import Notifier 6 | from dexter.core.log import LOG 7 | from dexter.core.util import fuzzy_list_range 8 | from dexter.service import Service, Handler, Result 9 | from fuzzywuzzy import fuzz 10 | 11 | import socket 12 | 13 | 14 | class _Handler(Handler): 15 | """ 16 | The handler for Wikipedia queries. 17 | """ 18 | def __init__(self, service, tokens, belief, host, port, thing, max_chars): 19 | """ 20 | @see Handler.__init__() 21 | 22 | :type thing: str 23 | :param thing: 24 | What, or who, is being asked about. 25 | """ 26 | super().__init__(service, tokens, belief, True) 27 | self._host = host 28 | self._port = port 29 | self._thing = str(thing) 30 | self._max_chars = max_chars 31 | 32 | 33 | def handle(self): 34 | """ 35 | @see Handler.handle() 36 | """ 37 | try: 38 | LOG.info("Sending '%s'" % (self._thing,)) 39 | sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 40 | sckt.connect((self._host, self._port)) 41 | sckt.sendall(self._thing.encode()) 42 | sckt.send(b'\0') 43 | 44 | LOG.info("Waiting for result...") 45 | result = b'' 46 | while True: 47 | got = sckt.recv(1) 48 | if len(got) == 0 or got == b'\0': 49 | break 50 | result += got 51 | result = result.decode() 52 | LOG.info("Got '%s'", result) 53 | 54 | # Trim it, in case it's huge 55 | result = result[:result[:self._max_chars].rindex('.')+1] 56 | LOG.info("Trimmed to '%s'", result) 57 | 58 | # And give it back 59 | return Result(self, result, False, True) 60 | 61 | except Exception as e: 62 | LOG.error("Failed to send '%s': %s", self._thing, e) 63 | return Result( 64 | self, 65 | "Sorry, there was a problem chatting about %s" % ( 66 | self._thing, 67 | ), 68 | False, 69 | True 70 | ) 71 | finally: 72 | try: 73 | sckt.close() 74 | except Exception: 75 | pass 76 | 77 | 78 | class ChatService(Service): 79 | """ 80 | A service which talks to a remote chat bot. 81 | """ 82 | # Look for these types of queston 83 | _PREFIXES = ('what is', 84 | 'whats', 85 | "what's", 86 | 'who is', 87 | 'tell me') 88 | 89 | def __init__(self, 90 | state, 91 | host =None, 92 | port =None, 93 | prefixes =_PREFIXES, 94 | max_belief=0.75, 95 | max_chars =250): 96 | """ 97 | @see Service.__init__() 98 | """ 99 | super().__init__("ChatBot", state) 100 | 101 | if host is None: 102 | raise ValueError("Not given a host") 103 | if port is None: 104 | raise ValueError("Not given a port") 105 | 106 | self._host = str(host) 107 | self._port = int(port) 108 | self._prefixes = tuple(prefix.split() for prefix in prefixes) 109 | self._max_belief = min(1.0, float(max_belief)) 110 | self._max_chars = int(max_chars) 111 | 112 | 113 | def evaluate(self, tokens): 114 | """ 115 | @see Service.evaluate() 116 | """ 117 | # Render to lower-case, for matching purposes. 118 | words = self._words(tokens) 119 | 120 | LOG.debug("Matching %s against %s", words, self._prefixes) 121 | for prefix in self._prefixes: 122 | try: 123 | # Look for the prefix in the words 124 | LOG.debug("Matching %s against %s", words, prefix) 125 | (start, end, score) = fuzzy_list_range(words, prefix) 126 | LOG.debug("%s matches %s with from %d to %d with score %d", 127 | prefix, words, start, end, score) 128 | if start == 0: 129 | # We always have a capped belief so that other services 130 | # which begin with "What's blah blah" can overrule us. 131 | thing = ' '.join(words).strip().lower() 132 | return _Handler(self, 133 | tokens, 134 | min(self._max_belief, score / 100), 135 | self._host, 136 | self._port, 137 | thing, 138 | self._max_chars) 139 | except ValueError: 140 | pass 141 | 142 | # If we got here then it didn't look like a query for us 143 | return None 144 | -------------------------------------------------------------------------------- /service/dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Services to aid with development and debugging. 3 | """ 4 | 5 | from dexter.service import Service, Handler, Result 6 | from dexter.core.util import fuzzy_list_range 7 | 8 | # ---------------------------------------------------------------------- 9 | 10 | class _EchoHandler(Handler): 11 | def __init__(self, service, tokens): 12 | """ 13 | @see Handler.__init__() 14 | """ 15 | super().__init__(service, tokens, 1.0, True) 16 | 17 | 18 | def handle(self): 19 | """ 20 | @see Handler.handle() 21 | """ 22 | return Result( 23 | self, 24 | "You said: %s" % ' '.join([token.element 25 | for token in self._tokens 26 | if token.verbal]), 27 | False, 28 | False 29 | ) 30 | 31 | 32 | class EchoService(Service): 33 | """ 34 | A service which simply parrots back what was given to it. 35 | """ 36 | def __init__(self, state): 37 | """ 38 | @see Service.__init__() 39 | """ 40 | super().__init__("Echo", state) 41 | 42 | 43 | def evaluate(self, tokens): 44 | """ 45 | @see Service.evaluate() 46 | """ 47 | # We always handle these 48 | return _EchoHandler(self, tokens) 49 | 50 | # ---------------------------------------------------------------------- 51 | 52 | class _MatchHandler(Handler): 53 | def __init__(self, service, tokens, matches, best): 54 | """ 55 | @see Handler.__init__() 56 | """ 57 | super().__init__(service, tokens, best / 100, True) 58 | self._matches = matches 59 | 60 | 61 | def handle(self): 62 | """ 63 | @see Handler.handle() 64 | """ 65 | result = list() 66 | for (phrase, words, (start, end, score)) in self._matches: 67 | result.append( 68 | "'%s' from [%d:%d] with '%s' scoring %d" % ( 69 | ' '.join(phrase), start, end, ' '.join(words), score 70 | ) 71 | ) 72 | 73 | return Result( 74 | self, 75 | "You matched: %s" % '; and '.join(result), 76 | False, 77 | False 78 | ) 79 | 80 | 81 | class MatchService(Service): 82 | """ 83 | A service which attempts to do an approximate match on a set of word lists. 84 | """ 85 | def __init__(self, state, phrases=[]): 86 | """ 87 | @see Service.__init__() 88 | """ 89 | super().__init__("Match", state) 90 | 91 | self._phrases = tuple( 92 | tuple( 93 | w.strip() 94 | for w in phrase.strip().split() 95 | if w.strip() 96 | ) 97 | for phrase in phrases 98 | ) 99 | 100 | 101 | def evaluate(self, tokens): 102 | """ 103 | @see Service.evaluate() 104 | """ 105 | # Look for a match in the phrases 106 | words = self._words(tokens) 107 | matches = [] 108 | best = None 109 | for phrase in self._phrases: 110 | try: 111 | result = fuzzy_list_range(phrase, words) 112 | matches.append((phrase, words, result)) 113 | (_, _, score) = result 114 | if best is None or best < score: 115 | best = score 116 | except ValueError: 117 | pass 118 | if len(matches) > 0: 119 | return _MatchHandler(self, tokens, tuple(matches), best) 120 | else: 121 | return None 122 | 123 | -------------------------------------------------------------------------------- /service/fortune.py: -------------------------------------------------------------------------------- 1 | """ 2 | Services related to the BSD Unix 'fortune' program. 3 | """ 4 | 5 | from dexter.service import Service, Handler, Result 6 | from dexter.core.log import LOG 7 | from dexter.core.util import fuzzy_list_range 8 | 9 | import os 10 | import random 11 | 12 | # ---------------------------------------------------------------------- 13 | 14 | # The FortuneService expects the BSD fortune datafiles to be present. 15 | # These can typically be install by doing something like: 16 | # sudo apt install fortune-mod fortunes 17 | # for Debian derivatives. The current location of the data-files is the 18 | # default value of the fortunes_dir argument. 19 | 20 | class _FortuneHandler(Handler): 21 | def __init__(self, service, tokens, fortune): 22 | """ 23 | @see Handler.__init__() 24 | """ 25 | super().__init__(service, tokens, 1.0, True) 26 | self._fortune = fortune 27 | 28 | 29 | def handle(self): 30 | """ 31 | @see Handler.handle() 32 | """ 33 | return Result(self, self._fortune, True, False) 34 | 35 | 36 | class FortuneService(Service): 37 | """ 38 | A service which pulls out text from the fortune files and delivers it to the 39 | user. 40 | """ 41 | def __init__(self, 42 | state, 43 | phrase ="Tell me something", 44 | fortunes_dir ="/usr/share/games/fortunes", 45 | fortune_files=tuple(), 46 | max_length =200): 47 | """ 48 | @see Service.__init__() 49 | 50 | :type phrase: str 51 | :param phrase: 52 | The key-phrase to look for as a trigger. 53 | :type fortunes_dir: str 54 | :param fortunes_dir: 55 | The location of the fortune data files. 56 | :type max_length: int 57 | :param max_length: 58 | The maximum length of a selected fortune, in bytes. 59 | """ 60 | super().__init__("Fortune", state) 61 | 62 | self._phrase = [str(word).lower() for word in phrase.split() if word] 63 | self._dir = fortunes_dir 64 | self._filenames = fortune_files 65 | self._max_len = int(max_length) 66 | 67 | 68 | def evaluate(self, tokens): 69 | """ 70 | @see Service.evaluate() 71 | """ 72 | try: 73 | # If we match the phrase then pick a fortune! 74 | (s, e, _) = fuzzy_list_range(self._phrase, 75 | self._words(tokens)) 76 | if s == 0 and e == len(self._phrase): 77 | fortune = self._pick() 78 | if not fortune: 79 | fortune = "I don't have anything to say today it seems" 80 | 81 | # Now possibly tweak the text of the fortune so it works for 82 | # reading out loud 83 | fortune = self._speechify(fortune) 84 | 85 | return _FortuneHandler(self, tokens, fortune) 86 | 87 | except ValueError: 88 | # No match 89 | pass 90 | 91 | # Not for us it seems 92 | return None 93 | 94 | 95 | def _pick(self): 96 | """ 97 | Choose a random fortune. This is the meat of this class. 98 | """ 99 | # We look in both the forturn directory and the list of files given 100 | filenames = list(self._filenames) 101 | for (subdir, _, files) in os.walk(self._dir, followlinks=True): 102 | for filename in files: 103 | filenames.append(os.path.join(subdir, filename)) 104 | 105 | # Now we look at all the files we found and turn them into one huge 106 | # one. We do this all from scratch each time since it's not _that_ 107 | # expensive and it means we don't have to restart anything when new 108 | # files are added. We have a list of filenames and the start and end of 109 | # their data as part of the total count. 110 | # 111 | # We are effectively concatenating the files here so as to avoid 112 | # bias. Consider: if you have two files, with one twice the size of the 113 | # other, if we picked a random fortune from a random file then then 114 | # fortunes in the smallee file would be twice as likely to come up as 115 | # ones in the bigger one. 116 | file_info = [] 117 | total_size = 0 118 | for path in filenames: 119 | # The fortune files have an associated .dat file, this means we 120 | # can identify them by looking for that .dat file. 121 | dat_path = path + '.dat' 122 | LOG.debug("Candidate: %s %s", path, dat_path) 123 | if not os.path.exists(dat_path): 124 | LOG.info("Skipping file with missing .dat: %s", path) 125 | else: 126 | # Open it to make sure can do so 127 | try: 128 | with open(path, 'rt'): 129 | # Get the file length to use it to accumulate into 130 | # our running counter, and to compute the file- 131 | # specifc stats. 132 | stat = os.stat(path) 133 | 134 | # The start of the file is the current total_size 135 | # and the end is that plus the file size 136 | start = total_size 137 | total_size += stat.st_size 138 | end = total_size 139 | file_info.append((path, start, end)) 140 | LOG.debug("Adding %s[%d:%d]", path, start, end) 141 | except Exception as e: 142 | LOG.debug("Failed to add %s: %s", path, e) 143 | 144 | 145 | # Keep trying this until we get something, or until we give up. Most of 146 | # the time we expect this to work on the first go unless something weird 147 | # is going on. 148 | for tries in range(10): 149 | LOG.debug("Try #%d", tries) 150 | 151 | # Now that we have a list of files, pick one at random by choosing a 152 | # point somewhere in there 153 | offset = random.randint(0, total_size) 154 | LOG.debug("Picked offset %d", offset) 155 | 156 | # Now we look for the file which contains that offset 157 | for (filename, start, end) in file_info: 158 | if start <= offset < end: 159 | with open(filename, 'rt') as fh: 160 | # Jump to the appropriate point in the file, according to 161 | # the offset (relative to the files's start in the overall 162 | # set) 163 | seek_offset = offset - start 164 | if seek_offset > 0: 165 | fh.seek(seek_offset) 166 | 167 | try: 168 | # Now look for the bracketing '%'s. Read in a nice 169 | # big chunk and hunt for it in there. 170 | chunk = fh.read(min(10 * self._max_len, 1024 * 1024)) 171 | 172 | # The file could start with a bracketer and we want 173 | # to catch that 174 | if seek_offset == 0 and chunk.startswith('%\n'): 175 | s = 2 176 | else: 177 | s = chunk.index('\n%\n') + 3 178 | 179 | # Now look for the end. A properly-formed file 180 | # should have a '%\n' as its last line. 181 | e = chunk.index('\n%\n', s) 182 | 183 | # We found a match. Is it small enough? 184 | LOG.debug("Found section %s[%d:%d]", filename, s, e) 185 | if (e - s) > self._max_len: 186 | # Nope, go around and try again 187 | break 188 | else: 189 | # Yes! 190 | return chunk[s:e] 191 | 192 | except ValueError: 193 | # Find to match so give up and go around again 194 | break 195 | 196 | # If we got here then we gave up trying 197 | return None 198 | 199 | 200 | def _speechify(self, fortune): 201 | """ 202 | Turn the text of the fortune into something which works for reading out 203 | loud. This could be things like turning roman numerals into words or 204 | "Q:" and "A:" into "question" and "answer". 205 | """ 206 | # But, for now, we do nothing... 207 | return fortune 208 | -------------------------------------------------------------------------------- /service/pandora.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple Pandora client, using `pydora` under the hood. 3 | 4 | This doesn't really work right now, it seems that you can't shut it up. However, 5 | it's here for completeness, and in the vain hope that one day I'll fix it. 6 | 7 | Before you can use this you will need to run ``pydora-configure`` to, err, 8 | configure `pydora`. 9 | """ 10 | 11 | from dexter.core.audio import MIN_VOLUME, MAX_VOLUME 12 | from dexter.core.log import LOG 13 | from dexter.service import Service, Handler, Result 14 | from pandora import clientbuilder 15 | from pydora.audio_backend import VLCPlayer 16 | from threading import Thread 17 | from .music import (MusicService, 18 | MusicServicePauseHandler, 19 | MusicServiceTogglePauseHandler, 20 | MusicServiceUnpauseHandler) 21 | 22 | import sys 23 | 24 | # ------------------------------------------------------------------------------ 25 | 26 | 27 | class PandoraServicePauseHandler(MusicServicePauseHandler): 28 | pass 29 | 30 | 31 | class PandoraServiceUnpauseHandler(MusicServiceUnpauseHandler): 32 | pass 33 | 34 | 35 | class PandoraServiceTogglePauseHandler(MusicServiceTogglePauseHandler): 36 | pass 37 | 38 | 39 | class _PandoraServicePlayHandler(Handler): 40 | def __init__(self, service, tokens, what, token, score): 41 | """ 42 | @see Handler.__init__() 43 | 44 | :type what: str 45 | :param what: 46 | What we are playing, like "Blah Blah by Fred" 47 | :type token: str 48 | :param token: 49 | The token from the search. 50 | :type score: float 51 | :param score: 52 | The match score out of 1.0. 53 | """ 54 | # We deem ourselves exclusive since we had a match 55 | super().__init__(service, tokens, score, True) 56 | self._token = token 57 | self._what = what 58 | 59 | 60 | def handle(self): 61 | """ 62 | @see Handler.handle()` 63 | """ 64 | LOG.info('Playing %s' % (self._what)) 65 | self.service.play(self._token) 66 | return Result(self, '', False, True) 67 | 68 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 69 | 70 | class Callbacks(): 71 | """ 72 | The player callback handlers. 73 | """ 74 | def __init__(self, service): 75 | self._service = service 76 | 77 | def play(self, song): 78 | LOG.info("Playing %s", song) 79 | 80 | 81 | def pre_poll(self): 82 | # Can be ignored 83 | pass 84 | 85 | 86 | def post_poll(self): 87 | # Can be ignored 88 | pass 89 | 90 | 91 | def input(self, cmd, song): 92 | LOG.error("Got command '%s' for song '%s'", cmd, song) 93 | 94 | # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 95 | 96 | class PandoraService(MusicService): 97 | """ 98 | Music service for local files. 99 | """ 100 | def __init__(self, state, config_file=''): 101 | """ 102 | @see Service.__init__() 103 | 104 | :type config_file: str 105 | :param config_file: 106 | The path to the config file, if not the default. 107 | """ 108 | super().__init__("PandoraService", state, "Pandora") 109 | 110 | self._config_file = config_file 111 | self._pandora = None 112 | self._player = None 113 | self._station = None 114 | 115 | 116 | def set_volume(self, volume): 117 | """ 118 | @see MusicService.set_volume() 119 | """ 120 | if MIN_VOLUME <= volume <= MAX_VOLUME: 121 | self._player._send_cmd('volume %d' % ( 122 | 100.0 * (volume - MIN_VOLUME) / (MAX_VOLUME - MIN_VOLUME) 123 | )) 124 | else: 125 | raise ValueError("Bad volume: %s", volume) 126 | 127 | 128 | def get_volume(): 129 | """ 130 | @see MusicService.get_volume() 131 | """ 132 | return self._player._send_cmd('volume') 133 | 134 | 135 | def is_playing(self): 136 | """ 137 | Whether the player is playing. 138 | 139 | :rtype: bool 140 | :return: 141 | Whether the player is playing. 142 | """ 143 | try: 144 | return int(self._player._send_cmd('is_playing')) 145 | except: 146 | return False 147 | 148 | 149 | def play(self, token): 150 | """ 151 | Set the song(s) to play. 152 | """ 153 | # Out with the old 154 | if self._station is not None: 155 | try: 156 | self._player.end_station() 157 | except: 158 | pass 159 | self._pandora.delete_station(self._station.token) 160 | 161 | # In with the new 162 | self._station = self._pandora.create_station(search_token=token) 163 | LOG.error("Playing station %s", self._station) 164 | 165 | # And play it, in a new thread since it is blocking 166 | thread = Thread(name='Pandora', target=self._play_station) 167 | thread.daemon = True 168 | thread.start() 169 | 170 | 171 | def pause(self): 172 | """ 173 | Pause any currently playing music. 174 | """ 175 | self._player.stop() 176 | self._player.end_station() 177 | 178 | 179 | def unpause(self): 180 | """ 181 | Resume any currently paused music. 182 | """ 183 | self._player._send_cmd('play') 184 | 185 | 186 | def _start(self): 187 | """ 188 | @see Startable._start() 189 | """ 190 | builder = clientbuilder.PydoraConfigFileBuilder('') 191 | if not builder.file_exists: 192 | raise ValueError("Unable to find config file; " 193 | "have you run pydora-config yet?") 194 | 195 | self._pandora = builder.build() 196 | LOG.info("Configured as device %s", self._pandora.device) 197 | 198 | self._player = VLCPlayer(Callbacks(self), sys.stdin) 199 | self._player.start() 200 | 201 | 202 | def _stop(self): 203 | """ 204 | @see Startable._stop() 205 | """ 206 | # Be tidy 207 | if self._station is not None: 208 | self._player.end_station() 209 | self._pandora.delete_station(self._station.token) 210 | self._station = None 211 | 212 | 213 | def _match_artist(self, artist): 214 | """ 215 | @see MusicService._match_artist() 216 | """ 217 | # Do a search and look for a "likely" artist match 218 | artist = ' '.join(artist) 219 | result = self._pandora.search(artist) 220 | return any(item.likely_match for item in result.artists) 221 | 222 | 223 | def _get_stop_handler(self, tokens): 224 | """ 225 | @see MusicService._get_stop_handler() 226 | """ 227 | return _PandoraServicePauseHandler(self, tokens) 228 | 229 | 230 | def _get_play_handler(self, tokens): 231 | """ 232 | @see MusicService._get_play_handler() 233 | """ 234 | return _PandoraServiceUnpauseHandler(self, tokens) 235 | 236 | 237 | def _get_toggle_pause_handler(self, tokens): 238 | """ 239 | @see MusicService._get_toggle_pause_handler() 240 | """ 241 | return _PandoraServiceTogglePauseHandler(self, tokens) 242 | 243 | 244 | def _get_handler_for(self, 245 | tokens, 246 | platform_match, 247 | genre, 248 | artist, 249 | song_or_album): 250 | """ 251 | @see MusicService._get_handler_for() 252 | """ 253 | # Do nothing if we have no name 254 | if song_or_album is None or len(song_or_album) == 0: 255 | return None 256 | 257 | # Normalise to strings 258 | name = ' '.join(song_or_album) 259 | if artist is None or len(artist) == 0: 260 | artist = None 261 | else: 262 | artist = ' '.join(artist) 263 | 264 | # Construct the search string 265 | search = name 266 | if artist is not None: 267 | search += " by " + artist 268 | 269 | # Do the search 270 | LOG.error("Looking for '%s'", search) 271 | result = self._pandora.search(search) 272 | LOG.error("Got: %s", result) 273 | 274 | # See if we got something back 275 | if len(result.songs) > 0: 276 | # Pick the best 277 | song = sorted(result.songs, 278 | key=lambda item: item.score, 279 | reverse=True)[0] 280 | 281 | # Grab what the handler needs 282 | what = "%s by %s" % (song.song_name, song.artist) 283 | score = song.score / 100.0 284 | token = song.token 285 | 286 | # And give back the handler to play it 287 | return _PandoraServicePlayHandler(self, tokens, what, token, score) 288 | 289 | else: 290 | # We got nothing 291 | return None 292 | 293 | 294 | def _play_station(self): 295 | """ 296 | Play the current station. This blocks. 297 | """ 298 | if self._station is not None: 299 | self._player.play_station(self._station) 300 | 301 | -------------------------------------------------------------------------------- /service/purpleair.py: -------------------------------------------------------------------------------- 1 | """ 2 | Get data from Purple Air and render it. 3 | 4 | You will need an API key to do this, as well as a sensor ID. Last tie I checked 5 | you could get an API key by mailing ``contact@purpleair.com``. 6 | """ 7 | 8 | from dexter.core.log import LOG 9 | from dexter.core.util import fuzzy_list_range 10 | from dexter.service import Service, Handler, Result 11 | 12 | import httplib2 13 | import json 14 | import os 15 | import time 16 | 17 | class _PurpleAirHandler(Handler): 18 | def __init__(self, service, tokens): 19 | """ 20 | @see Handler.__init__() 21 | """ 22 | super().__init__(service, tokens, 1.0, True) 23 | 24 | 25 | def _get_data(self): 26 | """ 27 | @see Handler.handle() 28 | """ 29 | # We'll want to cache the data since hammering PurpleAir is unfriendly 30 | # and also results in getting back no data. 31 | sensor_id = self.service.get_sensor_id() 32 | key = self.service.get_key() 33 | filename = '/tmp/dexter_purpleair_%s' % (sensor_id,) 34 | now = time.time() 35 | content = None 36 | 37 | # Look for a cached version which is less and a minute old 38 | try: 39 | ctime = os.stat(filename).st_ctime 40 | if now - ctime < 60: 41 | with open(filename, 'rb') as fh: 42 | content = fh.read() 43 | except IOError: 44 | pass 45 | 46 | # If we didn't have a good cached version then download it 47 | if not content: 48 | h = httplib2.Http() 49 | resp, content = \ 50 | h.request("https://api.purpleair.com/v1/sensors/%d" % (sensor_id,), 51 | "GET", 52 | headers={'content-type':'text/plain', 53 | 'X-API-Key' : key } ) 54 | 55 | # Save what we downloaded into the cache 56 | try: 57 | with open(filename, 'wb') as fh: 58 | fh.write(content) 59 | except IOError: 60 | pass 61 | 62 | # Now load in whatever we had 63 | raw = json.loads(content) 64 | 65 | # And pull out the first value from the "results" section, which should 66 | # be what we care about 67 | if 'sensor' not in raw or len(raw['sensor']) == 0: 68 | return {} 69 | else: 70 | LOG.debug("Got: %s", raw['sensor']) 71 | return raw['sensor'] 72 | 73 | 74 | class _AQHandler(_PurpleAirHandler): 75 | def __init__(self, service, tokens, raw): 76 | """ 77 | @see Handler.__init__() 78 | """ 79 | super().__init__(service, tokens) 80 | self._raw = raw 81 | 82 | 83 | def handle(self): 84 | """ 85 | @see Handler.handle() 86 | """ 87 | data = self._get_data() 88 | 89 | # We can look to derive the AQI from this 90 | value = data.get('pm2.5') 91 | if value is not None: 92 | # This is a very rough approximation to the AQI from the PM2_5Value 93 | value = float(value) 94 | aqi = value * value / 285 95 | if self._raw: 96 | what = "The air quality index %s is %d." % (aqi,) 97 | else: 98 | if aqi < 10: 99 | quality = "good" 100 | elif aqi < 50: 101 | quality = "okay" 102 | elif aiq < 100: 103 | quality = "acceptable" 104 | elif aiq < 150: 105 | quality = "poor" 106 | elif aiq < 200: 107 | quality = "bad" 108 | elif aiq < 250: 109 | quality = "hazardous" 110 | else: 111 | quality = "extremely hazardous" 112 | what = "The air quality is %s" % (quality,) 113 | else: 114 | what = "The air quality is unknown" 115 | 116 | # And give it back 117 | return Result( 118 | self, 119 | what, 120 | False, 121 | True 122 | ) 123 | 124 | 125 | class _HumidityHandler(_PurpleAirHandler): 126 | def __init__(self, service, tokens): 127 | """ 128 | @see Handler.__init__() 129 | """ 130 | super().__init__(service, tokens) 131 | 132 | 133 | def handle(self): 134 | """ 135 | @see Handler.handle() 136 | """ 137 | data = self._get_data() 138 | humidity = data.get('humidity') 139 | if humidity is not None: 140 | what = "%s percent" % (humidity,) 141 | else: 142 | what = "unknown" 143 | return Result( 144 | self, 145 | "The humidity is %s." % (what,), 146 | False, 147 | True 148 | ) 149 | 150 | 151 | class _TemperatureHandler(_PurpleAirHandler): 152 | def __init__(self, service, tokens): 153 | """ 154 | @see Handler.__init__() 155 | """ 156 | super().__init__(service, tokens) 157 | 158 | 159 | def handle(self): 160 | """ 161 | @see Handler.handle() 162 | """ 163 | # This comes in Fahrenheit, one day we'll convert it depending on user 164 | # tastes... 165 | data = self._get_data() 166 | temperature = data.get('temperature') 167 | if temperature is not None: 168 | what = "%s degrees fahrenheit" % (temperature,) 169 | else: 170 | what = "unknown" 171 | return Result( 172 | self, 173 | "The temperature is %s." % (what,), 174 | False, 175 | True 176 | ) 177 | 178 | 179 | class PurpleAirService(Service): 180 | """ 181 | A service which grabs data from a Purple Air station and uses it to give 182 | back the values therein. 183 | """ 184 | _HANDLERS = ((('air', 'quality', 'index'), 185 | lambda service, tokens: _AQHandler(service, tokens, True)), 186 | (('air', 'quality',), 187 | lambda service, tokens: _AQHandler(service, tokens, False)), 188 | (('humidity',), 189 | _HumidityHandler), 190 | (('temperature',), 191 | _TemperatureHandler),) 192 | _PREFICES = (('what', 'is', 'the',), 193 | ('whats', 'the',),) 194 | 195 | 196 | def __init__(self, state, sensor_id=None, api_key=None): 197 | """ 198 | @see Service.__init__() 199 | :type sensor_id: int 200 | :param sensor_id: 201 | The device ID. This can usually be found by looking at the sensor 202 | information on the Purple Air map (e.g. in the "Get this widget" 203 | tool-tip). This is required. 204 | :type api_key: str 205 | :param api_key: 206 | The API read key for the PurleAir API. This is reqiured. 207 | """ 208 | super().__init__("PurpleAir", state) 209 | 210 | if sensor_id is None: 211 | raise ValueError("Sensor ID was not given") 212 | if api_key is None: 213 | raise ValueError("API key was not given") 214 | self._sensor_id = sensor_id 215 | self._key = api_key 216 | 217 | 218 | def evaluate(self, tokens): 219 | """ 220 | @see Service.evaluate() 221 | """ 222 | words = self._words(tokens) 223 | for (what, handler) in self._HANDLERS: 224 | for prefix in self._PREFICES: 225 | phrase = (prefix + what) 226 | try: 227 | (s, e, _) = fuzzy_list_range(words, phrase) 228 | if s == 0 and e == len(phrase): 229 | return handler(self, tokens) 230 | except Exception as e: 231 | LOG.debug("Failed to handle '%s': %s" % (' '.join(words), e)) 232 | return None 233 | 234 | 235 | def get_sensor_id(self): 236 | """ 237 | Get the sensor ID. 238 | """ 239 | return self._sensor_id 240 | 241 | 242 | def get_key(self): 243 | """ 244 | Get the API key. 245 | """ 246 | return self._key 247 | -------------------------------------------------------------------------------- /service/randomness.py: -------------------------------------------------------------------------------- 1 | """ 2 | Services which simulate random processes (coins, dice, etc.). 3 | """ 4 | 5 | from dexter.core.log import LOG 6 | from dexter.core.util import fuzzy_list_range, parse_number 7 | from dexter.service import Service, Handler, Result 8 | 9 | import random 10 | 11 | class _CoinTossHandler(Handler): 12 | def __init__(self, service, tokens): 13 | """ 14 | @see Handler.__init__() 15 | """ 16 | super().__init__(service, tokens, 1.0, True) 17 | 18 | 19 | def handle(self): 20 | """ 21 | @see Handler.handle() 22 | """ 23 | result = "heads" if random.randint(0, 1) else "tails" 24 | return Result( 25 | self, 26 | "You got %s" % result, 27 | False, 28 | True 29 | ) 30 | 31 | 32 | class _DiceHandler(Handler): 33 | def __init__(self, service, tokens, sides): 34 | """ 35 | @see Handler.__init__() 36 | """ 37 | super().__init__(service, tokens, 1.0, True) 38 | self._sides = sides 39 | 40 | 41 | def handle(self): 42 | """ 43 | @see Handler.handle() 44 | """ 45 | return Result( 46 | self, 47 | "You got a %d" % random.randint(1, self._sides), 48 | False, 49 | True 50 | ) 51 | 52 | 53 | class _RangeHandler(Handler): 54 | def __init__(self, service, tokens, start, end): 55 | """ 56 | @see Handler.__init__() 57 | """ 58 | super().__init__(service, tokens, 1.0, False) 59 | self._start = start 60 | self._end = end 61 | 62 | 63 | def handle(self): 64 | """ 65 | @see Handler.handle() 66 | """ 67 | return Result( 68 | self, 69 | "%d" % random.randint(self._start, self._end), 70 | False, 71 | True 72 | ) 73 | 74 | 75 | class RandomService(Service): 76 | """ 77 | A service which handles different types of random number requests. 78 | """ 79 | def __init__(self, state): 80 | """ 81 | @see Service.__init__() 82 | """ 83 | super().__init__("Random", state) 84 | 85 | 86 | def evaluate(self, tokens): 87 | """ 88 | @see Service.evaluate() 89 | """ 90 | # The incoming request 91 | words = self._words(tokens) 92 | 93 | # Binary random number 94 | for phrase in ("toss a coin", "flip a coin"): 95 | try: 96 | fuzzy_list_range(words, phrase.split()) 97 | return _CoinTossHandler(self, tokens) 98 | except ValueError: 99 | pass 100 | 101 | # A regular die 102 | for phrase in ("roll a die", "roll a dice"): 103 | try: 104 | fuzzy_list_range(words, phrase.split()) 105 | return _DiceHandler(self, tokens, 6) 106 | except ValueError: 107 | pass 108 | 109 | # A generic request 110 | try: 111 | prefix = ('give', 'me', 'a', 'number', 'between') 112 | (_, offset, _) = fuzzy_list_range(words, prefix) 113 | if len(words) >= offset + 3: 114 | and_index = words.index('and') 115 | start = parse_number(words[offset :and_index]) 116 | end = parse_number(words[and_index+1:]) 117 | if start is not None and end is not None: 118 | return _RangeHandler(self, tokens, start, end) 119 | except Exception as e: 120 | LOG.debug("Failed to handle '%s': %s" % (phrase, e)) 121 | 122 | # Not for us 123 | return None 124 | -------------------------------------------------------------------------------- /service/volume.py: -------------------------------------------------------------------------------- 1 | """ 2 | Set the audio output volume. It goes up to 11. 3 | """ 4 | 5 | from dexter.core.audio import MIN_VOLUME, MAX_VOLUME, get_volume, set_volume 6 | from dexter.core.log import LOG 7 | from dexter.core.util import fuzzy_list_range, parse_number 8 | from dexter.service import Service, Handler, Result 9 | from fuzzywuzzy import fuzz 10 | 11 | import traceback 12 | 13 | class _SetHandler(Handler): 14 | """ 15 | Set the volume to a specific value. 16 | """ 17 | def __init__(self, service, tokens, volume): 18 | """ 19 | @see Handler.__init__() 20 | """ 21 | super().__init__(service, tokens, 1.0, True) 22 | self._volume = volume 23 | 24 | 25 | def handle(self): 26 | """ 27 | @see Handler.handle() 28 | """ 29 | try: 30 | value = parse_number(self._volume) 31 | LOG.info("Got value of %s from %s" % (value, self._volume)) 32 | 33 | if value < MIN_VOLUME or value > MAX_VOLUME: 34 | # Bad value 35 | return Result( 36 | self, 37 | "Sorry, volume needs to be between %d and %d" % 38 | (MIN_VOLUME, MAX_VOLUME), 39 | False, 40 | True 41 | ) 42 | else: 43 | # Acknowledge that it happened 44 | set_volume(value) 45 | return Result( 46 | self, 47 | "Okay, volume now %s" % (value,), 48 | False, 49 | True 50 | ) 51 | 52 | except Exception: 53 | LOG.error("Problem parsing volume '%s':\n%s" % 54 | (self._volume, traceback.format_exc())) 55 | return Result( 56 | self, 57 | "Sorry, I don't know how to set the volume to %s" % 58 | (self._volume,), 59 | False, 60 | True 61 | ) 62 | 63 | 64 | class _AdjustHandler(Handler): 65 | """ 66 | Raise or lower the volume by a delta. 67 | """ 68 | def __init__(self, service, tokens, delta): 69 | """ 70 | @see Handler.__init__() 71 | """ 72 | super().__init__(service, tokens, 1.0, True) 73 | self._delta = delta 74 | 75 | 76 | def handle(self): 77 | """ 78 | @see Handler.handle() 79 | """ 80 | try: 81 | # Make the change, capping at min and max 82 | cur = get_volume() 83 | new = max(MIN_VOLUME, min(MAX_VOLUME, cur + self._delta)) 84 | 85 | # Any change? 86 | if cur != new: 87 | # Acknowledge that it happened 88 | set_volume(new) 89 | direction = "Up" if self._delta > 0 else "Down" 90 | return Result(self, direction, False, True) 91 | else: 92 | # Nothing to do 93 | return None 94 | 95 | except Exception: 96 | LOG.error("Problem setting changing the volume by %s:\n%s" % 97 | (self._delta, traceback.format_exc())) 98 | return Result( 99 | self, 100 | "Sorry, there was a problem changing the volume", 101 | False, 102 | True 103 | ) 104 | 105 | 106 | class VolumeService(Service): 107 | """ 108 | A service for setting the volume. 109 | """ 110 | def __init__(self, state): 111 | """ 112 | @see Service.__init__() 113 | """ 114 | super().__init__("Volume", state) 115 | 116 | 117 | def evaluate(self, tokens): 118 | """ 119 | @see Service.evaluate() 120 | """ 121 | words = self._words(tokens) 122 | 123 | # Look for a direct setting 124 | prefix = ('set', 'volume', 'to') 125 | try: 126 | (start, end, _) = fuzzy_list_range(words, prefix) 127 | return _SetHandler(self, tokens, ' '.join(words[end:])) 128 | except: 129 | # Didn't find a match 130 | pass 131 | 132 | # For the below we need to up the threshold since: 133 | # fuzz.ratio('turn up the volume','turn down the volume') == 84 134 | 135 | # Or a request to raise... 136 | for prefix in (('raise', 'the', 'volume'), 137 | ('raise', 'volume'), 138 | ('turn', 'the', 'volume', 'up'), 139 | ('turn', 'up', 'the', 'volume')): 140 | try: 141 | (start, end, _) = fuzzy_list_range(words, prefix, threshold=85) 142 | if start == 0 and end == len(words): 143 | return _AdjustHandler(self, tokens, 1) 144 | except: 145 | # Didn't find a match 146 | pass 147 | 148 | # ...or lower the volume 149 | for prefix in (('lower', 'the', 'volume'), 150 | ('lower', 'volume'), 151 | ('turn', 'the', 'volume', 'down'), 152 | ('turn', 'down', 'the', 'volume')): 153 | try: 154 | (start, end, _) = fuzzy_list_range(words, prefix, threshold=85) 155 | if start == 0 and end == len(words): 156 | return _AdjustHandler(self, tokens, -1) 157 | except: 158 | # Didn't find a match 159 | pass 160 | 161 | # Or to shut up completely 162 | for phrase in ('mute', 163 | 'silence', 164 | 'quiet', 165 | 'shut up'): 166 | if fuzz.ratio(' '.join(words), phrase) > 80: 167 | return _SetHandler(self, tokens, 'zero') 168 | 169 | # Otherwise this was not for us 170 | return None 171 | 172 | 173 | -------------------------------------------------------------------------------- /service/wikiquery.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pull information from Wikipedia. 3 | """ 4 | 5 | from dexter.core import Notifier 6 | from dexter.core.log import LOG 7 | from dexter.core.util import fuzzy_list_range 8 | from dexter.service import Service, Handler, Result 9 | from fuzzywuzzy import fuzz 10 | 11 | import wikipedia 12 | 13 | 14 | class _Handler(Handler): 15 | """ 16 | The handler for Wikipedia queries. 17 | """ 18 | def __init__(self, service, tokens, belief, thing): 19 | """ 20 | @see Handler.__init__() 21 | 22 | :type thing: str 23 | :param thing: 24 | What, or who, is being asked about. 25 | """ 26 | super().__init__(service, tokens, belief, True) 27 | self._thing = str(thing) 28 | 29 | 30 | def handle(self): 31 | """ 32 | @see Handler.handle() 33 | """ 34 | try: 35 | LOG.info("Querying Wikipedia for '%s'" % (self._thing,)) 36 | summary = wikipedia.summary(self._thing, auto_suggest=False) 37 | except Exception as e: 38 | LOG.error("Failed to query Wikipedia about '%s': %s" % 39 | (self._thing, e)) 40 | return Result( 41 | self, 42 | "Sorry, there was a problem asking Wikipedia about %s" % ( 43 | self._thing, 44 | ), 45 | False, 46 | True 47 | ) 48 | 49 | # Anything? 50 | if summary is None or len(summary.strip()) == 0: 51 | return None 52 | 53 | # Strip the summary down a little, some of these can be pretty 54 | # long. First just grab the first paragraph. Next, stop after about 55 | # 400 chars. 56 | shortened = summary.split('\n')[0] 57 | if len(shortened) > 400: 58 | # Skip doing this if we don't find a sentence end marker 59 | try: 60 | index = shortened.index('. ', 398) 61 | shortened = shortened[:index+1] 62 | except ValueError: 63 | pass 64 | 65 | # And give it back. We use a period after "says" here so that the speech 66 | # output will pause appropriately. It's not good gramma though. Since we 67 | # got back a result then we mark ourselves as exclusive; there is 68 | # probably not a lot of point in having others also return information. 69 | return Result(self, 70 | "Wikipedia says.\n%s" % shortened, 71 | False, 72 | True) 73 | 74 | 75 | class WikipediaService(Service): 76 | """ 77 | A service which attempts to look for things on Wikipedia. 78 | 79 | >>> from dexter.test import NOTIFIER, tokenise 80 | >>> s = WikipediaService(NOTIFIER) 81 | >>> handler = s.evaluate(tokenise('who is mark shuttleworth')) 82 | >>> result = handler.handle() 83 | >>> result.text.startswith('Wikipedia says.\\nMark') 84 | True 85 | """ 86 | def __init__(self, 87 | state, 88 | max_belief=0.75): 89 | """ 90 | @see Service.__init__() 91 | """ 92 | super().__init__("Wikipedia", state) 93 | self._max_belief = min(1.0, float(max_belief)) 94 | 95 | 96 | def evaluate(self, tokens): 97 | """ 98 | @see Service.evaluate() 99 | """ 100 | # Render to lower-case, for matching purposes. 101 | words = self._words(tokens) 102 | 103 | # Look for these types of queston 104 | prefices = (('what', 'is', 'a'), 105 | ('what', 'is', 'the'), 106 | ('what', 'is'), 107 | ('who', 'is', 'the'), 108 | ('who', 'is')) 109 | match = None 110 | for prefix in prefices: 111 | try: 112 | # Look for the prefix in the words 113 | (start, end, score) = fuzzy_list_range(words, prefix) 114 | LOG.debug("%s matches %s with from %d to %d with score %d", 115 | prefix, words, start, end, score) 116 | if start == 0 and (match is None or match[2] < score): 117 | match = (start, end, score) 118 | except ValueError: 119 | pass 120 | 121 | # If we got a good match then use it 122 | if match: 123 | (start, end, score) = match 124 | thing = ' '.join(words[end:]).strip().lower() 125 | 126 | # Let's look to see if Wikipedia returns anything when we search 127 | # for this thing 128 | best = None 129 | try: 130 | self._notify(Notifier.ACTIVE) 131 | for result in wikipedia.search(thing): 132 | if result is None or len(result) == 0: 133 | continue 134 | score = fuzz.ratio(thing, result.lower()) 135 | LOG.debug("'%s' matches '%s' with a score of %d", 136 | result, thing, score) 137 | if best is None or best[1] < score: 138 | best = (result, score) 139 | except Exception as e: 140 | LOG.error("Failed to query Wikipedia for '%s': %s", 141 | thing, e) 142 | finally: 143 | self._notify(Notifier.IDLE) 144 | 145 | # Turn the words into a string for the handler 146 | if best is not None: 147 | # We always have a capped belief so that other services which 148 | # begin with "What's blah blah" can overrule us. 149 | belief = best[1] / 100 * self._max_belief 150 | return _Handler(self, tokens, belief, best[0]) 151 | 152 | # If we got here then it didn't look like a query for us 153 | return None 154 | 155 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pyc 2 | /__pycache__ 3 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Things to help with testing 3 | """ 4 | 5 | from dexter.core import Notifier 6 | 7 | # ------------------------------------------------------------------------------ 8 | 9 | class _TestNofifier(Notifier): 10 | """ 11 | A notifier for use in doctests which does nothing. 12 | """ 13 | def update_status(self, component, status): 14 | pass 15 | 16 | # ------------------------------------------------------------------------------ 17 | 18 | def tokenise(string): 19 | """ 20 | Turn a string into a list of tokens. 21 | """ 22 | from dexter.input import Token 23 | return [Token(e, 1.0, True) for e in string.split(' ')] 24 | 25 | tokenize = tokenise 26 | 27 | # ------------------------------------------------------------------------------ 28 | 29 | # Testing objects 30 | NOTIFIER = _TestNofifier() 31 | -------------------------------------------------------------------------------- /test_config: -------------------------------------------------------------------------------- 1 | { 2 | "key_phrases" : [ 3 | "Dexter", 4 | "Hey Computer" 5 | ], 6 | 7 | "components" : { 8 | "inputs" : [ 9 | [ "dexter.input.socket.SocketInput", { 10 | "port" : "8008", 11 | "prefix" : "Dexter" 12 | }] 13 | ], 14 | 15 | "outputs" : [ 16 | [ "dexter.output.io.LogOutput", { 17 | "level" : "INFO" 18 | }] 19 | ], 20 | 21 | "services" : [ 22 | [ "dexter.service.dev.EchoService", { 23 | }], 24 | 25 | [ "dexter.service.dev.MatchService", { 26 | "phrases" : [ 27 | "Where is my other sock", 28 | "What is the meaning of life", 29 | "Why do birds suddenly appear", 30 | "How did you know that" 31 | ] 32 | }] 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ubuntu_config: -------------------------------------------------------------------------------- 1 | // -*- mode: javascript -*- // 2 | 3 | /* 4 | * The example configuration file for running on a Raspberry Pi. 5 | * 6 | * For an explanation of the configuration file's format see the 7 | * example_config file. 8 | */ 9 | { 10 | "key_phrases" : [ 11 | "Dexter", 12 | "Hey Computer" 13 | ], 14 | 15 | "notifiers" : [ 16 | [ "dexter.notifier.logging.LogNotifier", { 17 | }] 18 | ], 19 | 20 | "components" : { 21 | "inputs" : [ 22 | [ "dexter.input.openai_whisper.WhisperInput", { 23 | "model" : "base" 24 | }] 25 | ], 26 | 27 | "outputs" : [ 28 | [ "dexter.output.io.LogOutput", { 29 | "level" : "INFO" 30 | }], 31 | 32 | [ "dexter.output.mycroft.Mimic3Output", { 33 | }] 34 | ], 35 | 36 | "services" : [ 37 | [ "dexter.service.chronos.ClockService", { 38 | }], 39 | 40 | [ "dexter.service.chronos.TimerService", { 41 | }], 42 | 43 | [ "dexter.service.volume.VolumeService", { 44 | }], 45 | 46 | [ "dexter.service.wikiquery.WikipediaService", { 47 | }], 48 | 49 | [ "dexter.service.music.LocalMusicService", { 50 | "dirname" : "${HOME}/Music" 51 | }], 52 | 53 | [ "dexter.service.randomness.RandomService", { 54 | }] 55 | ] 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /whisper_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Listen for incoming data and give back parsed results. 4 | 5 | This can be run a machine with a decent amount of oomph and the L{RemoteService} 6 | can talk to it instead of doing the speech-to-text locally. This is handy for 7 | when your client machine is just a Raspberry Pi. 8 | 9 | And, if other Home Hub providers can ship audio off from their Home Hub to the 10 | cloud to process, then it seems only fair that we can do something like that 11 | too. 12 | """ 13 | 14 | from threading import Lock, Thread 15 | 16 | import argparse 17 | import logging 18 | import numpy 19 | import os 20 | import socket 21 | import struct 22 | import time 23 | import whisper 24 | 25 | # ------------------------------------------------------------------------------ 26 | 27 | _MODELS = ('tiny', 'base', 'small', 'medium', 'large') 28 | _LOCK = Lock() 29 | 30 | # ------------------------------------------------------------------------------ 31 | 32 | def handle(conn, translate): 33 | """ 34 | Create a new thread for the given connection and start it. 35 | """ 36 | thread = Thread(target=lambda: run(conn, translate)) 37 | thread.daemon = True 38 | thread.start() 39 | 40 | 41 | def run(conn, translate): 42 | """ 43 | Handle a new connection, in its own thread. 44 | """ 45 | try: 46 | # Read in the header data 47 | logging.info("Reading header") 48 | header = b'' 49 | while len(header) < (3 * 8): 50 | got = conn.recv((3 * 8) - len(header)) 51 | if len(got) == 0: 52 | time.sleep(0.001) 53 | continue 54 | header += got 55 | 56 | # Unpack to variables 57 | (channels, width, rate) = struct.unpack('!qqq', header) 58 | logging.info("%d channel(s), %d byte(s) wide, %dHz", 59 | channels, width, rate) 60 | 61 | # We only handle 16kHz mono right now 62 | if rate != 16000: 63 | raise ValueError("Can only decode 16000Hz but had %dHz", rate) 64 | if channels != 1: 65 | raise ValueError("Can only decode 1 channel but had %d", channels) 66 | 67 | # Keep pulling in the data until we get an empty chunk 68 | data = b'' 69 | while True: 70 | # How big is this incoming chunk? 71 | length_bytes = b'' 72 | while len(length_bytes) < (8): 73 | got = conn.recv(8 - len(length_bytes)) 74 | if len(got) == 0: 75 | time.sleep(0.001) 76 | continue 77 | length_bytes += got 78 | (length,) = struct.unpack('!q', length_bytes) 79 | 80 | # End marker? 81 | if length < 0: 82 | logging.debug("Got end of data") 83 | break 84 | 85 | # Pull in the chunk 86 | logging.debug("Reading %d bytes of data", length) 87 | start = len(data) 88 | while len(data) - start < length: 89 | got = conn.recv(length - (len(data) - start)) 90 | if len(got) == 0: 91 | time.sleep(0.001) 92 | continue 93 | data += got 94 | 95 | # Convert to a numpy array. Whisper expects a numpy float array 96 | # normalised between +/-1.0.. 97 | if width == 1: 98 | audio = numpy.frombuffer(data, numpy.int8 ).astype(numpy.float32) / 2.0**7 99 | elif width == 2: 100 | audio = numpy.frombuffer(data, numpy.int16).astype(numpy.float32) / 2.0**15 101 | elif width == 4: 102 | audio = numpy.frombuffer(data, numpy.int32).astype(numpy.float32) / 2.0**31 103 | elif width == 8: 104 | audio = numpy.frombuffer(data, numpy.int64).astype(numpy.float32) / 2.0**63 105 | 106 | # Finally, decode it 107 | logging.info("Decoding %0.2f seconds of audio", 108 | len(data) / rate / width / channels) 109 | 110 | # Only one at a time so do this under a lock 111 | with _LOCK: 112 | result = model.transcribe(audio, 113 | task='translate' if translate else 'transcribe') 114 | 115 | # And give it back 116 | words = result['text'].strip() 117 | logging.info("Got: '%s'", words) 118 | 119 | # Send back the length (as a long) and the string 120 | words = words.encode() 121 | conn.sendall(struct.pack('!q', len(words))) 122 | conn.sendall(words) 123 | 124 | except Exception as e: 125 | # Tell the user at least 126 | logging.error("Error handling incoming data: %s", e) 127 | 128 | finally: 129 | # We're done with this connection now, close in a best-effort fashion 130 | try: 131 | logging.info("Closing connection") 132 | conn.shutdown(socket.SHUT_RDWR) 133 | conn.close() 134 | except: 135 | pass 136 | 137 | 138 | # ------------------------------------------------------------------------------ 139 | 140 | 141 | # Set up the logger 142 | logging.basicConfig( 143 | format='[%(asctime)s %(threadName)s %(filename)s:%(lineno)d %(levelname)s] %(message)s', 144 | level=logging.INFO 145 | ) 146 | 147 | # Parse the command line args 148 | parser = argparse.ArgumentParser(description='Running Whisper inference.') 149 | parser.add_argument('--model', default='base', 150 | help='The model type to use; one of %s' % ' '.join(_MODELS)) 151 | parser.add_argument('--port', type=int, default=8008, 152 | help='The port number to listen on') 153 | parser.add_argument('--translate', default=False, action='store_true', 154 | help='Whether to translate to English') 155 | args = parser.parse_args() 156 | 157 | # Pull in the model 158 | logging.info("Loading '%s' model", args.model,) 159 | model = whisper.load_model(args.model) 160 | 161 | # Set up the server socket 162 | logging.info("Opening socket on port %d", args.port) 163 | sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 164 | sckt.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 165 | sckt.bind(('0.0.0.0', args.port)) 166 | sckt.listen(5) 167 | 168 | # Do this forever 169 | while True: 170 | try: 171 | # Get a connection 172 | logging.info("Waiting for a connection") 173 | (conn, addr) = sckt.accept() 174 | logging.info("Got connection from %s" % (addr,)) 175 | handle(conn, args.translate) 176 | 177 | except Exception as e: 178 | # Tell the user at least 179 | logging.error("Error handling incoming connection: %s", e) 180 | --------------------------------------------------------------------------------