Source code for soundlib

"""soundlib (including SoundMaker, Tone)

This library provides a high-level interface for playing and mixing sounds of different types
on the CodeX. With this, sounds can also be played "in the background" while other code runs.

    * soundmaker = `SoundMaker`
    
    Example:
        Play a tone based on a supported instrument or waveform name

        >>> tone = soundlib.get_tone('trumpet')
        >>> tone.play()

.. list-table::
   :header-rows: 1

   * - Instrument
     - Waveform
   * - flute
     - sine
   * - recorder
     - triangle
   * - trumpet
     - square
   * - violin
     - sawtooth
   * - noise
     - random
   * - organ
     - (none)
   * - synth
     - see :py:meth:`set_synthbuf() <soundlib.Tone.set_synthbuf>`

"""

from codex import audio

import audiocore
import audiomixer
import audiomp3

from codex_drivers.funcgen import FuncSample

STANDARD_SAMPLE_RATE = 16000

#
# This class is intended to make things simple, and helps out
# automagically where it can. See for example set_level().
# Tone gives the user a single object to manipulate, versus
# having to deal with both a MixerVoice and a RawSample.
# This class is normally used in combination with the SoundMaker class.
#
[docs]class Tone(): """A Tone player with pitch and level control. Tones have variable pitch (frequency), and support several different wave shapes (waveforms). This is built atop the FuncSample class in the :py:mod:`funcgen` module. See that module for more details. """ # Associate familiar sound names to wave shapes aliases = { "flute" : "sine", "recorder" : "triangle", "trumpet" : "square", "violin" : "sawtooth", "noise" : "random", } func_types = { "sine": 0, "square": 1, "triangle": 2, "sawtooth": 3, "random": 4, "organ": 5, "synth": 6, } modulator_types = { "fm": 0, "am": 1, } def __init__(self, voice, sample): """Tone uses an audiomixer voice to play a FuncSample sample.""" self.voice = voice self.sample = sample # The following is for the automagic level control self.active_voices = 1 # default to assuming we are the only one self.set_pitch(440) self.sample.env_release() self.voice_play() def _set_active_voices(self, quantity): """Used by SoundMaker to enforce reasonable sound levels""" relative_volume = self.voice.level level = relative_volume * self.active_voices self.active_voices = quantity relative_volume = level / self.active_voices self.voice.level = relative_volume
[docs] def play(self): """Start tone. Use envelope to avoid clicking and provide soft note-shaping.""" self.voice_play() # in case mixer voice has been stopped (e.g. record mode) self.sample.env_attack()
[docs] def stop(self): """Stop tone. Use envelope to avoid clicking and provide soft note-shaping.""" self.sample.env_release()
[docs] def voice_stop(self): """Turn off this mixer voice (abruptly, can 'click')""" self.voice.stop()
[docs] def voice_play(self): """Turn on this mixer voice (abruptly, can 'click')""" if not self.voice.playing: self.voice.play(self.sample, loop=True)
[docs] def set_pitch(self, value): """Set pitch to the specified value (in Hertz)""" self.sample.set_frequency(value) self.pitch = value
[docs] def glide(self, new_pitch, duration): """Glide from current frequency to new pitch, linearly over tm seconds. Args: pitch (float): Target frequency (Hz) tm (float): Duration of glide (time to reach target pitch) """ self.sample.glide(new_pitch, duration)
[docs] def set_level(self, value): """Set volume level to the specified relative value (0-100)""" # This API works in percent 0-100 but the underlying library works in 0.0-1.0 units value /= 100.0 # Maintain our automatic level adjustment. For example, if there are two voices # and you ask for 100%, what you get is 50% of the total available value /= self.active_voices self.voice.level = value
[docs] def set_wave(self, wave_name): """Set waveform to specified type (from func_types or aliases)""" self.sample.set_function(self.wave_func(wave_name))
[docs] def set_synthbuf(self, array_buf): """For "synth" waveform type, set the source array: 1-cycle of 16-bit samples. The array can be any length, and will be assumed to contain exactly one cycle. Create with: array_buf = array.array("h", [0] * length) """ self.sample.set_synthbuf(array_buf)
[docs] @staticmethod def wave_func(wave_name): """Return the FuncGen code corresponding to wave_name.""" if wave_name in Tone.aliases: wave_name = Tone.aliases[wave_name] # Default to sine wave if no match return Tone.func_types.get(wave_name, 0)
[docs] def set_envelope(self, a, d, s, r): """Configure envelope parameters. Initial settings are: (0.003, 0.010, 0.95, 0.003) Args: a (float): Attack (sec) - time from 0 to full amplitude d (float): Decay (sec) - time from full to Sustain amplitude s (float): Sustain (fraction) - proportion of full amplitude to hold r (float): Release (sec) - time from Sustain to 0 amplitude """ self.sample.set_envelope(a, d, s, r)
[docs] def set_modulator(self, mod_name, rate, depth_buf): """Configure a modulator (LFO) The supplied depth_buf must have nonzero length and will be assumed to represent exactly 1-cycle of the LFO waveform. Each modulator has its own independent LFO. Args: mod_name (str): Which modulator ("fm", "am") rate (float): LFO frequency (Hz). set to ZERO to disable modulator. depth_buf (array("h")): Array of 16-bit signed samples """ param = self.modulator_types.get(mod_name, 0) # default to "fm" self.sample.set_modulator(param, rate, depth_buf)
# # The point of this helper class it to make MP3 file playback work similar # to the Tone class #
[docs]class MP3(): """A MP3 player with level control""" def __init__(self, voice, filename): self.voice = voice # The following is for the automagic level control self.active_voices = 1 # default to assuming we are the only one try: if not filename.endswith('.mp3'): filename += '.mp3' self.mp3_file = open(filename, "rb") try: self.mp3_decoder = audiomp3.MP3Decoder(self.mp3_file) except: print(str(filename) + " is not a valid MP3 file") except: print(str(filename) + " is not a valid file") def _set_active_voices(self, quantity): """Used by SoundMaker to enforce reasonable sound levels""" relative_volume = self.voice.level level = relative_volume * self.active_voices self.active_voices = quantity relative_volume = level / self.active_voices self.voice.level = relative_volume
[docs] def stop(self): """Stop generating sound (be quiet)""" self.voice.stop()
[docs] def play(self, loop=False): """Begin generating sound""" self.voice.play(self.mp3_decoder, loop=loop)
[docs] def set_level(self, value): """Start volume level to the specified relative value (0-100)""" # This API works in percent 0-100 but the underlying library works in 0.0-1.0 units value /= 100.0 # Maintain our automatic level adjustment. For example, if there are two voices # and you ask for 100%, what you get is 50% of the total available value /= self.active_voices self.voice.level = value
# # The point of this helper class it to make WAV file playback work similar # to the Tone class #
[docs]class WAV(): """A WAV player with level control""" def __init__(self, voice, filename): self.voice = voice # The following is for the automagic level control self.active_voices = 1 # default to assuming we are the only one try: self.wav_file = open(filename, "rb") try: self.wav_decoder = audiocore.WaveFile(self.wav_file) except: print(str(filename) + " is not a valid WAV file") except: print(str(filename) + " is not a valid file") def _set_active_voices(self, quantity): """Used by SoundMaker to enforce reasonable sound levels""" relative_volume = self.voice.level level = relative_volume * self.active_voices self.active_voices = quantity relative_volume = level / self.active_voices self.voice.level = relative_volume
[docs] def stop(self): """Stop generating sound (be quiet)""" self.voice.stop()
[docs] def play(self, loop=False): """Begin generating sound""" self.voice.play(self.wav_decoder, loop=loop)
[docs] def set_level(self, value): """Start volume level to the specified relative value (0-100)""" # This API works in percent 0-100 but the underlying library works in 0.0-1.0 units value /= 100.0 # Maintain our automatic level adjustment. For example, if there are two voices # and you ask for 100%, what you get is 50% of the total available value /= self.active_voices self.voice.level = value
[docs]class Sample(): """A sample player with level control""" def __init__(self, voice, sample): self.voice = voice self.sample = sample # The following is for the automagic level control self.active_voices = 1 # default to assuming we are the only one def _set_active_voices(self, quantity): """Used by SoundMaker to enforce reasonable sound levels""" relative_volume = self.voice.level level = relative_volume * self.active_voices self.active_voices = quantity relative_volume = level / self.active_voices self.voice.level = relative_volume
[docs] def stop(self): """Stop generating sound (be quiet)""" self.voice.stop()
[docs] def play(self, loop=False): """Begin (or resume if stop() was called) generating sound""" if not self.voice.playing: self.voice.play(self.sample, loop=loop)
[docs] def set_level(self, value): """Start volume level to the specified relative value (0-100)""" # This API works in percent 0-100 but the underlying library works in 0.0-1.0 units value /= 100.0 # Maintain our automatic level adjustment. For example, if there are two voices # and you ask for 100%, what you get is 50% of the total available value /= self.active_voices self.voice.level = value
[docs]class SoundMaker(): """Provides sounds which can be mixed together concurrently (polyphonic!)""" def __init__(self, max_concurrent_sounds=16, sample_rate=STANDARD_SAMPLE_RATE): if hasattr(self, '__doc__'): return # Autodoc stop here self.max_sounds = max_concurrent_sounds self.sample_rate = sample_rate self.sounds_in_use = 0 self.players = [] self.mixer = audiomixer.Mixer(voice_count=self.max_sounds, sample_rate=self.sample_rate, channel_count=1, bits_per_sample=16, samples_signed=True) audio.initialize() audio.set_audio_mixer(self.mixer)
[docs] def get_tone(self, waveform_name="sine"): """Get a Tone object so you can make some noise!""" if self.sounds_in_use >= self.max_sounds: print("Maximum requested number of sounds exceeded") return None ft = Tone.wave_func(waveform_name) sample = FuncSample(ft, self.sample_rate) tone = Tone(self.mixer.voice[self.sounds_in_use], sample) self.add_sound(tone) return tone
[docs] def get_sample(self, sample_buf, play=True, loop=False): """Get a sample object so you can play it. This can be a buffer directly recorded from the CodeX mic, or any 16-bit array you create using `array.array` ("h") and fill with samples. Note: sample rate of sample_buf must match self.sample_rate or pitch will be wonky! """ if self.sounds_in_use >= self.max_sounds: print("Maximum requested number of sounds exceeded") return None raw_sample = audiocore.RawSample(sample_buf, channel_count=1, sample_rate=self.sample_rate) sample = Sample(self.mixer.voice[self.sounds_in_use], raw_sample) self.add_sound(sample) if play: sample.play(loop) return sample
[docs] def get_mp3(self, filename, play=True, loop=False): """Get a MP3 object so you can play it""" if self.sounds_in_use >= self.max_sounds: print("Maximum requested number of sounds exceeded") return None mp3 = MP3(self.mixer.voice[self.sounds_in_use], filename) self.add_sound(mp3) if play: mp3.play(loop) return mp3
[docs] def get_wav(self, filename, play=True, loop=False): """Get a WAV object so you can play it""" if self.sounds_in_use >= self.max_sounds: print("Maximum requested number of sounds exceeded") return None wav = WAV(self.mixer.voice[self.sounds_in_use], filename) self.add_sound(wav) if play: wav.play(loop) return wav
def add_sound(self, sound): self.players.append(sound) self.sounds_in_use += 1 # Automatically reduce level (volume) settings for index in range(self.sounds_in_use): self.players[index]._set_active_voices(self.sounds_in_use)
[docs] def stop(self): """Invoke the stop() command on all the active sound players""" for index in range(self.sounds_in_use): self.players[index].stop()
[docs] def play(self, loop=False): """Invoke the play() command on all the active sound players""" for index in range(self.sounds_in_use): self.players[index].play(loop)
[docs] def reset(self): """Stop and remove all mixer voices.""" for i in range(self.max_sounds): self.mixer.stop_voice(i) self.sounds_in_use = 0 self.players = []
# Create the global SoundMaker instance soundmaker = SoundMaker()