# HG changeset patch # User drewp # Date 1576802740 28800 # Node ID bf05f33c3b3a188923676cb30a873d0c6bba5056 # Parent 9d22aa924e8aaf477234b5a9f177942d9455dc49 start rewrite for playSound Ignore-this: a4d4ccceea8ef0ba996ca2d81bf634c6 darcs-hash:76e853b388c95a2e4ba83637170effe872e997a8 diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/Dockerfile --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/Dockerfile Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,28 @@ +FROM bang6:5000/base_x86 + +WORKDIR /opt + + + apt install libsdl-mixer-dev + apt install libsdl-mixer1.2-dev +wget https://www.pygame.org/ftp/pygame-1.9.6.tar.gz + 34 tar xvzf pygame-1.9.6.tar.gz + 35 cd pygame-1.9.6 + python3 setup.py install +python3 -c 'import pygame; print(pygame.__version__, pygame.__file__); pygame.mixer.init()' + + +RUN touch need-new-update +RUN apt-get update +RUN apt-get install --yes libopenal1 libogg0 pulseaudio-utils festival sox python-pygame +COPY requirements.txt ./ +RUN pip3 install --index-url https://projects.bigasterisk.com/ --extra-index-url https://pypi.org/simple -r requirements.txt + +ENV SDL_AUDIODRIVER pulse +ENV PULSE_SERVER /tmp/pulseaudio +COPY pulse-client.conf /etc/pulse/client.conf +COPY *.py req* *.jade ./ + +EXPOSE 9049 + +CMD [ "python3", "./playSound.py" ] diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/Dockerfile.pi --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/Dockerfile.pi Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,17 @@ +FROM bang6:5000/base_pi + +WORKDIR /opt + +RUN apt-get update +RUN apt-get install --yes libopenal1 libogg0 pulseaudio-utils python-pygame festival sox +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY *.py req* *.jade ./ +COPY pulse-client.conf /etc/pulse/client.conf + +ENV PULSE_SERVER /tmp/pulseaudio +EXPOSE 9049 +RUN mkdir /sounds + +CMD [ "python", "./playSound.py" ] diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/pulse-client.conf --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/pulse-client.conf Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,8 @@ +default-server = /tmp/pulseaudio + +# Prevent a server running in the container +autospawn = no +daemon-binary = /bin/true + +# Prevent the use of shared memory +enable-shm = false diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/readme --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/readme Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,6 @@ +replacement for playSound; assume we do TTS somewhere else and then have +this service (and also chromecasts, etc) play it. + +It's still a goal for this service to do low-latency stuff like +feedback beeps for button presses, so it should cache and stay ready +to play. diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/requirements.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/requirements.txt Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,10 @@ +cyclone +mock==1.0.1 +Twisted + +# upgrading to 1.9.5 makes it stop working with pulseaudio +# (maybe this unversioned one picks the system package which +# has a better SDL build) +pygame + +standardservice==0.6.0 diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/serv.n3 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/serv.n3 Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,24 @@ +@prefix : . +@prefix auth: . +@prefix serv: . + +serv:speechmusic_dash a :Service; + :path "/speech_music/dash/"; + :openid auth:admin; + :serverHost "bang"; + :internalPort 9049; + :prodDockerFlags ( + "-p" "9049:9049" + "--net=host"); + :localDockerFlags ( + "--cap-add" "SYS_PTRACE" + #"--mount type=bind,source=/etc/pulse,target=/etc/pulse" + "--mount" "type=tmpfs,destination=/tmp,tmpfs-size=52428800" + "--mount" "type=bind,source=/tmp/pulseaudio,target=/tmp/pulseaudio" + ); + :localRunCmdline ( + "strace" "-ftts" "999" + "python3" "playSound.py" "-v"); + :dockerFile "Dockerfile" +. + diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/speechMusic.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/speechMusic.py Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,138 @@ +""" +play sounds according to POST requests. +""" +from docopt import docopt +import cyclone.web +import sys, tempfile, itertools +from twisted.internet import reactor +from cyclone.httpclient import fetch +from generator import tts +import xml.etree.ElementTree as ET +from twisted.web.static import File +from standardservice.logsetup import log, verboseLogging +import os + +soundCount = itertools.count() + +def makeSpeech(speech, fast=False): + speechWav = tempfile.NamedTemporaryFile(suffix='.wav') + + root = ET.Element("SABLE") + r = ET.SubElement(root, "RATE", + attrib=dict(SPEED="+50%" if fast else "+0%")) + for sentence in speech.split('.'): + div = ET.SubElement(r, "DIV") + div.set("TYPE", "sentence") + div.text = sentence + + speechSecs = tts(speech, speechWav.name) + return pygame.mixer.Sound(speechWav.name), speechSecs + +class LOADING(object): pass + +class SoundEffects(object): + def __init__(self): + self.buffers = {} # URIRef : pygame.mixer.Sound + self.playingSources = [] + self.queued = [] + self.volume = 1 # level for the next sound that's played (or existing instances of the same sound) + + def _getSound(self, uri): + def done(resp): + path = '/tmp/sound_%s' % next(soundCount) + with open(path, 'w') as out: + out.write(resp.body) + log.info('write %s bytes to %s', len(resp.body), path) + self.buffers[uri] = pygame.mixer.Sound(path) + + return fetch(uri).addCallback(done).addErrback(log.error) + + def playEffect(self, uri): + if uri not in self.buffers: + self.buffers[uri] = LOADING + self._getSound(uri).addCallback(lambda ret: self.playEffect(uri)) + return + if self.buffers[uri] is LOADING: + # The first playback loads then plays, but any attempts + # during that load are dropped, not queued. + return + snd = self.buffers[uri] + snd.set_volume(self.volume) + return self.playBuffer(snd) + + def playSpeech(self, txt, preEffect=None, postEffect=None, preEffectOverlap=0): + buf, secs = makeSpeech(txt) + t = 0 + if preEffect: + t += self.playEffect(preEffect) + + self.playingSources.append(buf) + reactor.callLater(secs + .1, self.done, buf) + return secs + + def done(self, src): + try: + self.playingSources.remove(src) + except ValueError: + pass + + def stopAll(self): + while self.playingSources: + self.playingSources.pop().stop() + for q in self.queued: + q.cancel() + # doesn't cover the callLater ones + + +class Index(cyclone.web.RequestHandler): + def get(self): + self.render('index.html', effectNames=[ + dict(name=k, postUri='effects/%s' % k) + for k in self.settings.sfx.buffers.keys()]) + +class Speak(cyclone.web.RequestHandler): + def post(self): + self.settings.sfx.playSpeech(self.get_argument('msg')) + return "ok" + +class PlaySound(cyclone.web.RequestHandler): + def post(self): + uri = self.get_argument('uri') + self.settings.sfx.playEffect(uri) + return "ok" + +class Volume(cyclone.web.RequestHandler): + def put(self): + self.settings.sfx.setVolume(float(self.get_argument('v'))) + return "ok" + +class StopAll(cyclone.web.RequestHandler): + def post(self): + self.settings.sfx.stopAll() + return "ok" + + +if __name__ == '__main__': + arg = docopt(''' + Usage: playSound.py [options] + + -v Verbose + ''') + verboseLogging(arg['-v']) + + import pygame + print('mixer init pulse') + import pygame.mixer + pygame.mixer.init() + sfx = SoundEffects() + + reactor.listenTCP(9049, cyclone.web.Application(handlers=[ + (r'/', Index), + (r'/speak', Speak), + (r'/playSound', PlaySound), + (r'/volume', Volume), + (r'/stopAll', StopAll), + (r'/static/(.*)', cyclone.web.StaticFileHandler, {'path': 'static'}), + ], template_path='.', sfx=sfx)) + reactor.run() +server.app.run(endpoint_description=r"tcp6:port=9049:interface=\:\:") diff -r 9d22aa924e8a -r bf05f33c3b3a service/playSound/tasks.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/playSound/tasks.py Thu Dec 19 16:45:40 2019 -0800 @@ -0,0 +1,30 @@ +from invoke import task, Collection + +import sys +sys.path.append('/my/proj/release') +from serv_tasks import serv_tasks + +ns = Collection() +serv_tasks(ns, 'serv.n3', 'speechmusic_dash') + + +''' +pactl_test: build_image + docker run --rm -it --cap-add SYS_PTRACE --net=host -v /tmp/pulseaudio:/tmp/pulseaudio ${TAG} pactl stat + +paplay_test_that_is_loud: build_image + docker run --rm -it --cap-add SYS_PTRACE --net=host -v /tmp/pulseaudio:/tmp/pulseaudio ${TAG} paplay /usr/local/lib/python2.7/dist-packages/pygame/examples/data/whiff.wav + +pygame_test: build_image + docker run --rm -it --cap-add SYS_PTRACE --net=host -e SDL_AUDIOSERVER=pulseaudio -v /tmp/pulseaudio:/tmp/pulseaudio ${TAG} python -c 'import os; print os.environ; import pygame.mixer; pygame.mixer.init()' + + +local_run: build_image + docker run --rm -it -p ${PORT}:${PORT} \ + --name=$(JOB)_local \ + --net=host \ + --mount type=tmpfs,destination=/tmp,tmpfs-size=52428800 \ + -v /tmp/pulseaudio:/tmp/pulseaudio \ + ${TAG} \ + python playSound.py -v +''' diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/Dockerfile --- a/service/speechMusic/Dockerfile Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,17 +0,0 @@ -FROM bang6:5000/base_x86 - -WORKDIR /opt - -RUN apt-get update -RUN apt-get install --yes libopenal1 libogg0 pulseaudio-utils python-pygame festival sox -COPY requirements.txt ./ -RUN pip install -r requirements.txt - -ENV SDL_AUDIODRIVER pulse -ENV PULSE_SERVER /tmp/pulseaudio -COPY pulse-client.conf /etc/pulse/client.conf -COPY *.py req* *.jade ./ - -EXPOSE 9049 - -CMD [ "python", "./speechMusic.py" ] diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/Dockerfile.pi --- a/service/speechMusic/Dockerfile.pi Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,18 +0,0 @@ -FROM bang6:5000/base_pi - -WORKDIR /opt - -RUN apt-get update -RUN apt-get install --yes libopenal1 libogg0 pulseaudio-utils python-pygame festival sox -COPY requirements.txt ./ -RUN pip install -r requirements.txt - -COPY sound sound -COPY *.py req* *.jade ./ -COPY pulse-client.conf /etc/pulse/client.conf - -ENV PULSE_SERVER /tmp/pulseaudio -EXPOSE 9049 -RUN mkdir /sounds - -CMD [ "python", "./speechMusic.py" ] diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/index.jade --- a/service/speechMusic/index.jade Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,59 +0,0 @@ -doctype html -html - head - title - | speechMusic - style - input[type=text] { - font-size: 40px; - font-family: monospace; - font-variant: small-caps; - width: 100%; - } - button { - margin: 10px; - padding: 10px; - } - body - h1 Speech - h2 Speak - form(method='POST', action='speak') - input(type='text', name='msg', autocomplete="off") - button(type='submit') Say - - h2 Effects - ul - each effect in effectNames - li - form(method='POST', action="#{effect['postUri']}") - button(type='submit') - | #{effect['name']} - - h2 Stop all - form(method='POST', action='stopAll') - button(type='submit') Stop all sounds - - p#status - - script(src="//bigasterisk.com/lib/jquery-2.0.3.min.js") - script(type="text/javascript") - $("input[type=text]").focus(); - - $("form").submit(function() { - $("#status").text("submitting..."); - $.ajax({ - data: $(this).serialize(), - url: this.action, - type: this.method, - success: function (result) { - $("#status").text(result); - $(this).find("input").val(""); - }.bind(this), - error: function (xhr, status, err) { - $("#status").text("Error: "+err); - } - }); - $("input[type=text]").focus(); - return false; - }); - \ No newline at end of file diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/makefile --- a/service/speechMusic/makefile Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,75 +0,0 @@ -JOB=speech_music -PORT=9049 - -TAG=bang6:5000/${JOB}_x86:latest -TAG_PI=bang6:5000/${JOB}_pi:latest - -prep_ctx: - rm -rf tmp_ctx - mkdir -p tmp_ctx - cp -a Dockerfile* ../../lib/*.py *.py *.jade req* *.conf /my/proj/csigen/generator.py tmp_ctx - -build_image: prep_ctx - docker build --network=host -t ${TAG} tmp_ctx - -push_image: build_image - docker push ${TAG} - - -build_image_pi: prep_ctx - docker build --network=host --file=tmp_ctx/Dockerfile.pi -t ${TAG_PI} tmp_ctx - -push_image_pi: build_image_pi - docker push ${TAG_PI} - -shell: build_image - docker run --rm -it \ - --cap-add SYS_PTRACE \ - --name=$(JOB)_shell \ - --mount type=tmpfs,destination=/tmp,tmpfs-size=52428800 \ - --net=host \ - -v /tmp/pulseaudio:/tmp/pulseaudio \ - ${TAG} \ - /bin/bash - -pactl_test: build_image - docker run --rm -it --cap-add SYS_PTRACE --net=host -v /tmp/pulseaudio:/tmp/pulseaudio ${TAG} pactl stat - -paplay_test_that_is_loud: build_image - docker run --rm -it --cap-add SYS_PTRACE --net=host -v /tmp/pulseaudio:/tmp/pulseaudio ${TAG} paplay /usr/local/lib/python2.7/dist-packages/pygame/examples/data/whiff.wav - -pygame_test: build_image - docker run --rm -it --cap-add SYS_PTRACE --net=host -e SDL_AUDIOSERVER=pulseaudio -v /tmp/pulseaudio:/tmp/pulseaudio ${TAG} python -c 'import os; print os.environ; import pygame.mixer; pygame.mixer.init()' - - -local_run: build_image - docker run --rm -it -p ${PORT}:${PORT} \ - --name=$(JOB)_local \ - --net=host \ - --mount type=tmpfs,destination=/tmp,tmpfs-size=52428800 \ - -v /tmp/pulseaudio:/tmp/pulseaudio \ - ${TAG} \ - python speechMusic.py -v - -local_run_strace: build_image - docker run --rm -it -p ${PORT}:${PORT} \ - -v `pwd`:/mnt \ - --name=$(JOB)_local \ - --net=host \ - -v /tmp/pulseaudio:/tmp/pulseaudio \ - --mount type=tmpfs,destination=/tmp,tmpfs-size=52428800 \ - --cap-add SYS_PTRACE \ - ${TAG} \ - strace -f -tts 200 python /mnt/speechMusic.py -v - -fresh_sudo: - sudo -v - -redeploy: fresh_sudo push_image push_image_pi - sudo /my/proj/ansible/playbook -l with_speakers -t $(JOB) - supervisorctl -s http://dash:9001/ restart $(JOB)_$(PORT) - supervisorctl -s http://slash:9001/ restart $(JOB)_$(PORT) - supervisorctl -s http://bed:9001/ restart $(JOB)_$(PORT) - supervisorctl -s http://living:9001/ restart $(JOB)_$(PORT) - supervisorctl -s http://kitchen:9001/ restart $(JOB)_$(PORT) - diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/pulse-client.conf --- a/service/speechMusic/pulse-client.conf Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,8 +0,0 @@ -default-server = /tmp/pulseaudio - -# Prevent a server running in the container -autospawn = no -daemon-binary = /bin/true - -# Prevent the use of shared memory -enable-shm = false diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/readme --- a/service/speechMusic/readme Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -this is meant to be run in multiple rooms with various output device configs diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/requirements.txt --- a/service/speechMusic/requirements.txt Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,12 +0,0 @@ -Jinja2==2.7 -klein==17.10.0 -Mako==0.8.1 -MarkupSafe==0.18 -mock==1.0.1 -pyjade==2.0.2 -Twisted - -# upgrading to 1.9.5 makes it stop working with pulseaudio -# (maybe this unversioned one picks the system package which -# has a better SDL build) -pygame diff -r 9d22aa924e8a -r bf05f33c3b3a service/speechMusic/speechMusic.py --- a/service/speechMusic/speechMusic.py Wed Dec 18 17:23:34 2019 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,148 +0,0 @@ -#!bin/python -""" -play sounds according to POST requests. -""" -from __future__ import division -import sys, tempfile, itertools -from pyjade.ext.mako import preprocessor as mako_preprocessor -from mako.lookup import TemplateLookup -from twisted.internet import reactor -from cyclone.httpclient import fetch -from generator import tts -import xml.etree.ElementTree as ET -from klein import Klein -from twisted.web.static import File -from logsetup import log -import pygame.mixer -class URIRef(str): pass - -soundCount = itertools.count() -templates = TemplateLookup(directories=['.'], - preprocessor=mako_preprocessor, - filesystem_checks=True) - -def makeSpeech(speech, fast=False): - speechWav = tempfile.NamedTemporaryFile(suffix='.wav') - - root = ET.Element("SABLE") - r = ET.SubElement(root, "RATE", - attrib=dict(SPEED="+50%" if fast else "+0%")) - for sentence in speech.split('.'): - div = ET.SubElement(r, "DIV") - div.set("TYPE", "sentence") - div.text = sentence - - speechSecs = tts(speech, speechWav.name) - return pygame.mixer.Sound(speechWav.name), speechSecs - -class LOADING(object): pass - -class SoundEffects(object): - def __init__(self): - self.buffers = {} # URIRef : pygame.mixer.Sound - self.playingSources = [] - self.queued = [] - self.volume = 1 # level for the next sound that's played (or existing instances of the same sound) - - def _getSound(self, uri): - def done(resp): - path = '/tmp/sound_%s' % next(soundCount) - with open(path, 'w') as out: - out.write(resp.body) - log.info('write %s bytes to %s', len(resp.body), path) - self.buffers[uri] = pygame.mixer.Sound(path) - - return fetch(uri).addCallback(done).addErrback(log.error) - - def playEffect(self, uri): - if uri not in self.buffers: - self.buffers[uri] = LOADING - self._getSound(uri).addCallback(lambda ret: self.playEffect(uri)) - return - if self.buffers[uri] is LOADING: - # The first playback loads then plays, but any attempts - # during that load are dropped, not queued. - return - snd = self.buffers[uri] - snd.set_volume(self.volume) - return self.playBuffer(snd) - - def playSpeech(self, txt, preEffect=None, postEffect=None, preEffectOverlap=0): - buf, secs = makeSpeech(txt) - t = 0 - if preEffect: - t += self.playEffect(preEffect) - t -= preEffectOverlap - - reactor.callLater(t, self.playBuffer, buf) - t += secs - - if postEffect: - self.playBufferLater(t, self.buffers[postEffect]) - - def playBufferLater(self, t, buf): - self.queued.append(reactor.callLater(t, self.playBuffer, buf)) - - def playBuffer(self, buf): - buf.play() - - secs = buf.get_length() - self.playingSources.append(buf) - reactor.callLater(secs + .1, self.done, buf) - return secs - - def done(self, src): - try: - self.playingSources.remove(src) - except ValueError: - pass - - def stopAll(self): - while self.playingSources: - self.playingSources.pop().stop() - for q in self.queued: - q.cancel() - # doesn't cover the callLater ones - -class Server(object): - app = Klein() - def __init__(self, sfx): - self.sfx = sfx - - @app.route('/static/', branch=True) - def static(self, request): - return File("./static") - - @app.route('/', methods=['GET']) - def index(self, request): - t = templates.get_template("index.jade") - return t.render(effectNames=[ - dict(name=k, postUri='effects/%s' % k) - for k in self.sfx.buffers.keys()]) - - @app.route('/speak', methods=['POST']) - def speak(self, request): - self.sfx.playSpeech(request.args['msg'][0]) - return "ok" - - @app.route('/playSound', methods=['POST']) - def effect(self, request): - uri = request.args['uri'][0] - self.sfx.playEffect(uri) - return "ok" - - @app.route('/volume', methods=['PUT']) - def volume(self, request, name): - self.sfx.setVolume(float(request.args['v'][0])) - return "ok" - - @app.route('/stopAll', methods=['POST']) - def stopAll(self, request): - self.sfx.stopAll() - return "ok" - -pygame.mixer.init() -sfx = SoundEffects() - -server = Server(sfx) -server.app.run(endpoint_description=r"tcp6:port=9049:interface=\:\:")