Top

rstem.sound module

This module provides interfaces to the Speaker RaspberrySTEM Cell.

Additionally, it can be used for any audio out over the analog audio jack.

#
# Copyright (c) 2014, Scott Silver Labs, LLC.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
'''
This module provides interfaces to the Speaker RaspberrySTEM Cell.

Additionally, it can be used for any audio out over the analog audio jack.
'''

import os
import sys
import time
import re
import io
from functools import partial
from . import mixer     # c extension
import tempfile
from threading import RLock, Thread, Condition, Event
from queue import Queue, Full
from subprocess import call, check_output
from struct import pack, unpack
import math

'''
    Future Sound class member function:
        def seek(self, position, absolute=False, percentage=False)
            - relative +/- seconds
            - absolute +/- seconds (-negative seconds from end)
            - absolute percentage
            - returns previous position, in seconds
'''

STOP, PLAY, FLUSH = range(3)
CHUNK_BYTES = 1024
SOUND_CACHE = '/home/pi/.rstem_sounds'
SOUND_DIR = '/opt/raspberrystem/sounds'

def shell_cmd(cmd):
    with open(os.devnull, "w") as devnull:
        call(cmd, stdout=devnull, stderr=devnull)

def master_volume(level):
    if level < 0 or level > 100:
        raise ValueError("level must be between 0 and 100.")

    shell_cmd('amixer sset PCM {}%'.format(int(level)).split())

class Players(object):
    def __init__(self):
        mixer.init()
        self.mutex = RLock()
        self.players = set()
        self.thread = Thread(target=self.daemon)
        self.thread.daemon = True
        self.thread.start()

    def add(self, sound):
        with self.mutex:
            self.players.add(sound)

    def remove(self, sound):
        with self.mutex:
            try:
                self.players.remove(sound)
            except KeyError:
                # Ignore deleting the same sound multiple times (of course,
                # also means we're ignoring deleting an invalid sound).
                pass

    def daemon(self):
        chunk = None
        while True:
            starting_sounds = []
            chunks = []
            gains = []
            with self.mutex:
                # For each player, get an audio buffer.
                for sound in frozenset(self.players):
                    count, chunk, gain = sound.play_q.get()
                    if count >= 0:
                        if count == 0:
                            starting_sounds.append(sound)
                        chunks.append(chunk)
                        gains.append(gain)
                    else:
                        sound.flush_done.set()
                        self.remove(sound)
            if chunks:
                for sound in starting_sounds:
                    sound.start_time = time.time()
                mixer.play(chunks, gains)
            else:
                mixer.play([bytes(CHUNK_BYTES)], [1])
            time.sleep(0.01)

class BaseSound(object):
    # Single instance of Players, for all sounds.
    players = Players()

    # Default master volume
    master_volume(100)

    def __init__(self):
        self._SAMPLE_RATE = 44100
        self._BYTES_PER_SAMPLE = 2
        self._CHANNELS = 1
        self._length = 0
        self.gain = 1
        self.internal_gain = 1
        self.loops = 1
        self.duration = 0
        self.stop_play_mutex = RLock()
        self.play_state = STOP
        self.play_count = 0
        self.do_stop = False
        self.cv_play_state = Condition()
        self.do_play = Event()
        self.flush_done = Event()
        self.start_time = None
        self.play_thread = Thread(target=self.__play_thread)
        self.play_thread.daemon = True
        self.play_thread.start()

    def length(self):
        '''Returns the length of the sound in seconds'''
        return self._length

    def is_playing(self):
        return self.play_state != STOP

    def wait(self, timeout=None):
        '''Wait until the sound has finished playing.'''
        self.__wait_for_state(STOP)
        if self.start_time:
            if self.duration == None:
                total_time = self._length
            else:
                total_time = self.duration
            total_time *= self.loops
            wait_time = total_time - (time.time() - self.start_time)
            if wait_time > 0:
                time.sleep(wait_time)
        return self

    def stop(self):
        if self.play_state != STOP:
            with self.stop_play_mutex:
                self.do_stop = True
                self.players.remove(self)

                # Flush the Queue.   Doh!  We don't really flush at all -
                # its too slow of an operation on the Queue.  Instead, we
                # just let the Queue be gc'ed, and we create a new one.
                self.flush_done.set()

                self.__wait_for_state(STOP)
        return self

    def play(self, loops=1, duration=None):
        if duration and duration < 0:
            raise ValueError("duration must be a positive number")
        with self.stop_play_mutex:
            self.stop()
            self.loops = loops
            self.duration = duration
            self.play_q = Queue(128)
            self.players.add(self)
            self.start_time = None
            previous_play_count = self.play_count
            self.do_play.set()
            self.__wait_for_state(PLAY, previous_play_count=previous_play_count)
        return self

    def __wait_for_state(self, state, previous_play_count=-1):
        # Wait until given state is reached.
        #
        # We handle the PLAY state in a special way: because a play can be very
        # short (for a short duration sound), we can't necessarily wait for it.
        # Instead, we keep a play_count that is incremented on each play, and
        # we wait for the count to get incremented.
        #
        with self.cv_play_state:
            self.cv_play_state.wait_for(
                lambda:self.play_state == state if state != PLAY else self.play_count != previous_play_count)

    def __set_state(self, state):
        with self.cv_play_state:
            self.play_state = state
            if state == PLAY:
                self.play_count += 1
            self.cv_play_state.notify()
    
    def __play_thread(self):
        while True:
            self.do_play.wait()
            self.__set_state(PLAY)

            chunk = self._chunker(self.loops, self.duration)
            count = 0

            self.do_stop = False
            while not self.do_stop:
                try:
                    self.play_q.put((count, next(chunk), self.gain), timeout=0.01)
                except Full:
                    pass
                except StopIteration:
                    self.play_q.put((-1, None, None)) # EOF
                    break
                count += 1

            self.__set_state(FLUSH)
            self.flush_done.wait()

            self.do_play.clear()
            self.flush_done.clear()
            self.__set_state(STOP)

    def _time_to_bytes(self, duration):
        if duration == None:
            return None
        samples = duration * self._SAMPLE_RATE
        return samples * self._BYTES_PER_SAMPLE

    @property
    def volume(self):
        return round(self.gain * self.internal_gain * 100)

    @volume.setter
    def volume(self, level):
        if level < 0:
            raise ValueError("level must be a positive number")
        self.gain = (level/100)/self.internal_gain

    # dummy chunking function
    def _chunker(self, loops, duration):
        return bytes(CHUNK_BYTES)

class Sound(BaseSound):
    def __init__(self, filename):
        '''A playable sound backed by the sound file `filename` on disk.
        
        Throws `IOError` if the sound file cannot be read.
        '''
        super().__init__()

        self.bytes = None
        if isinstance(filename, bytes):
            data = filename 
            self.file_opener = partial(io.BytesIO, data)
            byte_length = len(data)

        else:
            # normalize path, raltive to SOUND_DIR
            try:
                filename = os.path.normpath(os.path.join(SOUND_DIR, filename))
            except:
                raise ValueError("Filename '{}' is not valid".format(filename))

            # Is it a file?  Not a definitive test here, but used as a courtesy to
            # give a better error when the filename is wrong.
            if not os.path.isfile(filename):
                raise IOError("Sound file '{}' cannot be found".format(filename))

            # Create cached file
            if not os.path.isdir(SOUND_CACHE):
                os.makedirs(SOUND_CACHE)
        
            _, file_ext = os.path.splitext(filename)
            if file_ext != '.raw':
                # Use sox to convert sound file to raw cached sound
                elongated_file_name = re.sub('/', '_', filename)
                raw_name = os.path.join(SOUND_CACHE, elongated_file_name)

                # If cached file doesn't exist, create it using sox
                if not os.path.isfile(raw_name):
                    soxcmd = ['sox',
                        '-q',
                        filename,
                        '-L',
                        '-r44100',
                        '-b16',
                        '-c1',
                        '-traw',
                        raw_name]
                    shell_cmd(soxcmd)
                    # test error
                filename = raw_name

            self.file_opener = partial(open, filename, 'rb')

            byte_length = os.path.getsize(filename)

        self._length = round(byte_length / (self._SAMPLE_RATE * self._BYTES_PER_SAMPLE), 6)

    def _chunker(self, loops, duration):
        with self.file_opener() as f:
            duration_bytes = self._time_to_bytes(duration)
            leftover = b''
            for loop in reversed(range(loops)):
                f.seek(0)
                bytes_written = 0
                while duration_bytes == None or bytes_written < duration_bytes:
                    if leftover:
                        chunk = leftover + f.read(CHUNK_BYTES - len(leftover))
                        leftover = b''
                    else:
                        chunk = f.read(CHUNK_BYTES)
                    if chunk:
                        if len(chunk) < CHUNK_BYTES and loop > 0:
                            # Save partial chunk as leftovers
                            leftover = chunk
                            break
                        else:
                            # Pad silence, if we're on the last loop and it's not a full chunk
                            if loop == 0:
                                chunk = chunk + bytes(CHUNK_BYTES)[len(chunk):]
                            bytes_written += CHUNK_BYTES
                            yield chunk
                    else:
                        # EOF
                        break

class Note(BaseSound):
    def __init__(self, pitch):
        '''
        '''
        super().__init__()

        A4_frequency = 440
        A6_frequency = A4_frequency * 2 * 2

        try:
            self.frequency = float(pitch)
        except ValueError:
            match = re.search('^([A-G])([b#]?)([0-9]?)$', pitch)
            if not match:
                raise ValueError("pitch parameter must be a frequency or note (e.g. 'A', 'B#', or 'Cb4'")
            note, semitone, octave = match.groups()

            if not semitone:
                semitone_adjust = 0
            elif semitone == 'b':
                semitone_adjust = -1
            else:
                semitone_adjust = 1

            if not octave:
                octave = 4
            octave = int(octave)

            half_step_map = {'C' : 0, 'D' : 2, 'E' : 4, 'F' : 5, 'G' : 7, 'A' : 9, 'B' : 11}
            half_steps = octave * 12 + half_step_map[note]

            half_steps += semitone_adjust

            # Adjust half steps relative to A4 440Hz
            half_steps -= 4 * 12 + 9

            self.frequency = 2 ** (half_steps / 12.0) * A4_frequency

        # Simple bass boost: scale up the volume of lower frequency notes.  For
        # each octave below a 'A6', double the volume
        if self.frequency < A6_frequency:
            self.internal_gain = A6_frequency / self.frequency

    def play(self, duration=1):
        super().play(duration=duration)
        return self

    def _chunker(self, loops, duration):
        if duration == None:
            chunks = 999999999
        else:
            chunks = int((self._time_to_bytes(duration) * loops) / CHUNK_BYTES)
        for chunk in range(chunks):
            yield mixer.note(chunk, float(self.frequency))

class Speech(Sound):
    def __init__(self, text, espeak_options=''):
        '''
        '''
        wav_fd, wav_name = tempfile.mkstemp(suffix='.wav')
        os.system('espeak {} -w {} "{}"'.format(espeak_options, wav_name, text))
        os.close(wav_fd)
        self.wav_name = wav_name
        super().__init__(wav_name)

    def __del__(self):
        os.remove(self.wav_name)
        
__all__ = ['Sound', 'Note', 'Speech', 'master_volume']

Functions

def master_volume(

level)

def master_volume(level):
    if level < 0 or level > 100:
        raise ValueError("level must be between 0 and 100.")

    shell_cmd('amixer sset PCM {}%'.format(int(level)).split())

Classes

class Note

class Note(BaseSound):
    def __init__(self, pitch):
        '''
        '''
        super().__init__()

        A4_frequency = 440
        A6_frequency = A4_frequency * 2 * 2

        try:
            self.frequency = float(pitch)
        except ValueError:
            match = re.search('^([A-G])([b#]?)([0-9]?)$', pitch)
            if not match:
                raise ValueError("pitch parameter must be a frequency or note (e.g. 'A', 'B#', or 'Cb4'")
            note, semitone, octave = match.groups()

            if not semitone:
                semitone_adjust = 0
            elif semitone == 'b':
                semitone_adjust = -1
            else:
                semitone_adjust = 1

            if not octave:
                octave = 4
            octave = int(octave)

            half_step_map = {'C' : 0, 'D' : 2, 'E' : 4, 'F' : 5, 'G' : 7, 'A' : 9, 'B' : 11}
            half_steps = octave * 12 + half_step_map[note]

            half_steps += semitone_adjust

            # Adjust half steps relative to A4 440Hz
            half_steps -= 4 * 12 + 9

            self.frequency = 2 ** (half_steps / 12.0) * A4_frequency

        # Simple bass boost: scale up the volume of lower frequency notes.  For
        # each octave below a 'A6', double the volume
        if self.frequency < A6_frequency:
            self.internal_gain = A6_frequency / self.frequency

    def play(self, duration=1):
        super().play(duration=duration)
        return self

    def _chunker(self, loops, duration):
        if duration == None:
            chunks = 999999999
        else:
            chunks = int((self._time_to_bytes(duration) * loops) / CHUNK_BYTES)
        for chunk in range(chunks):
            yield mixer.note(chunk, float(self.frequency))

Ancestors (in MRO)

  • Note
  • rstem.sound.BaseSound
  • builtins.object

Class variables

var players

Static methods

def __init__(

self, pitch)

def __init__(self, pitch):
    '''
    '''
    super().__init__()
    A4_frequency = 440
    A6_frequency = A4_frequency * 2 * 2
    try:
        self.frequency = float(pitch)
    except ValueError:
        match = re.search('^([A-G])([b#]?)([0-9]?)$', pitch)
        if not match:
            raise ValueError("pitch parameter must be a frequency or note (e.g. 'A', 'B#', or 'Cb4'")
        note, semitone, octave = match.groups()
        if not semitone:
            semitone_adjust = 0
        elif semitone == 'b':
            semitone_adjust = -1
        else:
            semitone_adjust = 1
        if not octave:
            octave = 4
        octave = int(octave)
        half_step_map = {'C' : 0, 'D' : 2, 'E' : 4, 'F' : 5, 'G' : 7, 'A' : 9, 'B' : 11}
        half_steps = octave * 12 + half_step_map[note]
        half_steps += semitone_adjust
        # Adjust half steps relative to A4 440Hz
        half_steps -= 4 * 12 + 9
        self.frequency = 2 ** (half_steps / 12.0) * A4_frequency
    # Simple bass boost: scale up the volume of lower frequency notes.  For
    # each octave below a 'A6', double the volume
    if self.frequency < A6_frequency:
        self.internal_gain = A6_frequency / self.frequency

def is_playing(

self)

def is_playing(self):
    return self.play_state != STOP

def length(

self)

Returns the length of the sound in seconds

def length(self):
    '''Returns the length of the sound in seconds'''
    return self._length

def play(

self, duration=1)

def play(self, duration=1):
    super().play(duration=duration)
    return self

def stop(

self)

def stop(self):
    if self.play_state != STOP:
        with self.stop_play_mutex:
            self.do_stop = True
            self.players.remove(self)
            # Flush the Queue.   Doh!  We don't really flush at all -
            # its too slow of an operation on the Queue.  Instead, we
            # just let the Queue be gc'ed, and we create a new one.
            self.flush_done.set()
            self.__wait_for_state(STOP)
    return self

def wait(

self, timeout=None)

Wait until the sound has finished playing.

def wait(self, timeout=None):
    '''Wait until the sound has finished playing.'''
    self.__wait_for_state(STOP)
    if self.start_time:
        if self.duration == None:
            total_time = self._length
        else:
            total_time = self.duration
        total_time *= self.loops
        wait_time = total_time - (time.time() - self.start_time)
        if wait_time > 0:
            time.sleep(wait_time)
    return self

Instance variables

var volume

class Sound

class Sound(BaseSound):
    def __init__(self, filename):
        '''A playable sound backed by the sound file `filename` on disk.
        
        Throws `IOError` if the sound file cannot be read.
        '''
        super().__init__()

        self.bytes = None
        if isinstance(filename, bytes):
            data = filename 
            self.file_opener = partial(io.BytesIO, data)
            byte_length = len(data)

        else:
            # normalize path, raltive to SOUND_DIR
            try:
                filename = os.path.normpath(os.path.join(SOUND_DIR, filename))
            except:
                raise ValueError("Filename '{}' is not valid".format(filename))

            # Is it a file?  Not a definitive test here, but used as a courtesy to
            # give a better error when the filename is wrong.
            if not os.path.isfile(filename):
                raise IOError("Sound file '{}' cannot be found".format(filename))

            # Create cached file
            if not os.path.isdir(SOUND_CACHE):
                os.makedirs(SOUND_CACHE)
        
            _, file_ext = os.path.splitext(filename)
            if file_ext != '.raw':
                # Use sox to convert sound file to raw cached sound
                elongated_file_name = re.sub('/', '_', filename)
                raw_name = os.path.join(SOUND_CACHE, elongated_file_name)

                # If cached file doesn't exist, create it using sox
                if not os.path.isfile(raw_name):
                    soxcmd = ['sox',
                        '-q',
                        filename,
                        '-L',
                        '-r44100',
                        '-b16',
                        '-c1',
                        '-traw',
                        raw_name]
                    shell_cmd(soxcmd)
                    # test error
                filename = raw_name

            self.file_opener = partial(open, filename, 'rb')

            byte_length = os.path.getsize(filename)

        self._length = round(byte_length / (self._SAMPLE_RATE * self._BYTES_PER_SAMPLE), 6)

    def _chunker(self, loops, duration):
        with self.file_opener() as f:
            duration_bytes = self._time_to_bytes(duration)
            leftover = b''
            for loop in reversed(range(loops)):
                f.seek(0)
                bytes_written = 0
                while duration_bytes == None or bytes_written < duration_bytes:
                    if leftover:
                        chunk = leftover + f.read(CHUNK_BYTES - len(leftover))
                        leftover = b''
                    else:
                        chunk = f.read(CHUNK_BYTES)
                    if chunk:
                        if len(chunk) < CHUNK_BYTES and loop > 0:
                            # Save partial chunk as leftovers
                            leftover = chunk
                            break
                        else:
                            # Pad silence, if we're on the last loop and it's not a full chunk
                            if loop == 0:
                                chunk = chunk + bytes(CHUNK_BYTES)[len(chunk):]
                            bytes_written += CHUNK_BYTES
                            yield chunk
                    else:
                        # EOF
                        break

Ancestors (in MRO)

  • Sound
  • rstem.sound.BaseSound
  • builtins.object

Class variables

var players

Static methods

def __init__(

self, filename)

A playable sound backed by the sound file filename on disk.

Throws IOError if the sound file cannot be read.

def __init__(self, filename):
    '''A playable sound backed by the sound file `filename` on disk.
    
    Throws `IOError` if the sound file cannot be read.
    '''
    super().__init__()
    self.bytes = None
    if isinstance(filename, bytes):
        data = filename 
        self.file_opener = partial(io.BytesIO, data)
        byte_length = len(data)
    else:
        # normalize path, raltive to SOUND_DIR
        try:
            filename = os.path.normpath(os.path.join(SOUND_DIR, filename))
        except:
            raise ValueError("Filename '{}' is not valid".format(filename))
        # Is it a file?  Not a definitive test here, but used as a courtesy to
        # give a better error when the filename is wrong.
        if not os.path.isfile(filename):
            raise IOError("Sound file '{}' cannot be found".format(filename))
        # Create cached file
        if not os.path.isdir(SOUND_CACHE):
            os.makedirs(SOUND_CACHE)
    
        _, file_ext = os.path.splitext(filename)
        if file_ext != '.raw':
            # Use sox to convert sound file to raw cached sound
            elongated_file_name = re.sub('/', '_', filename)
            raw_name = os.path.join(SOUND_CACHE, elongated_file_name)
            # If cached file doesn't exist, create it using sox
            if not os.path.isfile(raw_name):
                soxcmd = ['sox',
                    '-q',
                    filename,
                    '-L',
                    '-r44100',
                    '-b16',
                    '-c1',
                    '-traw',
                    raw_name]
                shell_cmd(soxcmd)
                # test error
            filename = raw_name
        self.file_opener = partial(open, filename, 'rb')
        byte_length = os.path.getsize(filename)
    self._length = round(byte_length / (self._SAMPLE_RATE * self._BYTES_PER_SAMPLE), 6)

def is_playing(

self)

def is_playing(self):
    return self.play_state != STOP

def length(

self)

Returns the length of the sound in seconds

def length(self):
    '''Returns the length of the sound in seconds'''
    return self._length

def play(

self, loops=1, duration=None)

def play(self, loops=1, duration=None):
    if duration and duration < 0:
        raise ValueError("duration must be a positive number")
    with self.stop_play_mutex:
        self.stop()
        self.loops = loops
        self.duration = duration
        self.play_q = Queue(128)
        self.players.add(self)
        self.start_time = None
        previous_play_count = self.play_count
        self.do_play.set()
        self.__wait_for_state(PLAY, previous_play_count=previous_play_count)
    return self

def stop(

self)

def stop(self):
    if self.play_state != STOP:
        with self.stop_play_mutex:
            self.do_stop = True
            self.players.remove(self)
            # Flush the Queue.   Doh!  We don't really flush at all -
            # its too slow of an operation on the Queue.  Instead, we
            # just let the Queue be gc'ed, and we create a new one.
            self.flush_done.set()
            self.__wait_for_state(STOP)
    return self

def wait(

self, timeout=None)

Wait until the sound has finished playing.

def wait(self, timeout=None):
    '''Wait until the sound has finished playing.'''
    self.__wait_for_state(STOP)
    if self.start_time:
        if self.duration == None:
            total_time = self._length
        else:
            total_time = self.duration
        total_time *= self.loops
        wait_time = total_time - (time.time() - self.start_time)
        if wait_time > 0:
            time.sleep(wait_time)
    return self

Instance variables

var bytes

var volume

class Speech

class Speech(Sound):
    def __init__(self, text, espeak_options=''):
        '''
        '''
        wav_fd, wav_name = tempfile.mkstemp(suffix='.wav')
        os.system('espeak {} -w {} "{}"'.format(espeak_options, wav_name, text))
        os.close(wav_fd)
        self.wav_name = wav_name
        super().__init__(wav_name)

    def __del__(self):
        os.remove(self.wav_name)

Ancestors (in MRO)

Class variables

var players

Static methods

def __init__(

self, text, espeak_options='')

def __init__(self, text, espeak_options=''):
    '''
    '''
    wav_fd, wav_name = tempfile.mkstemp(suffix='.wav')
    os.system('espeak {} -w {} "{}"'.format(espeak_options, wav_name, text))
    os.close(wav_fd)
    self.wav_name = wav_name
    super().__init__(wav_name)

def is_playing(

self)

def is_playing(self):
    return self.play_state != STOP

def length(

self)

Returns the length of the sound in seconds

def length(self):
    '''Returns the length of the sound in seconds'''
    return self._length

def play(

self, loops=1, duration=None)

def play(self, loops=1, duration=None):
    if duration and duration < 0:
        raise ValueError("duration must be a positive number")
    with self.stop_play_mutex:
        self.stop()
        self.loops = loops
        self.duration = duration
        self.play_q = Queue(128)
        self.players.add(self)
        self.start_time = None
        previous_play_count = self.play_count
        self.do_play.set()
        self.__wait_for_state(PLAY, previous_play_count=previous_play_count)
    return self

def stop(

self)

def stop(self):
    if self.play_state != STOP:
        with self.stop_play_mutex:
            self.do_stop = True
            self.players.remove(self)
            # Flush the Queue.   Doh!  We don't really flush at all -
            # its too slow of an operation on the Queue.  Instead, we
            # just let the Queue be gc'ed, and we create a new one.
            self.flush_done.set()
            self.__wait_for_state(STOP)
    return self

def wait(

self, timeout=None)

Wait until the sound has finished playing.

def wait(self, timeout=None):
    '''Wait until the sound has finished playing.'''
    self.__wait_for_state(STOP)
    if self.start_time:
        if self.duration == None:
            total_time = self._length
        else:
            total_time = self.duration
        total_time *= self.loops
        wait_time = total_time - (time.time() - self.start_time)
        if wait_time > 0:
            time.sleep(wait_time)
    return self

Instance variables

var volume

var wav_name