"""
Play WAV tones.
"""
import wave
from enum import Enum
from io import BytesIO
from random import choice
try:
import winsound
except ImportError:
# ReadTheDocs uses linux to build to docs, so winsound is not available
winsound = None
import numpy as np
from .log import logger
def _freq(i, j):
return 440 * (2 ** ((i + (j * 12) - 57) / 12))
SHARPS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
FLATS = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']
NOTES: dict[str, float] = dict(
(f'{note}{octave}', _freq(i, octave))
for octave in range(11)
for i, note in enumerate(SHARPS)
)
NOTES.update(dict(
(f'{note}{octave}', _freq(i, octave))
for octave in range(11)
for i, note in enumerate(FLATS)
))
[docs]
class Theme(Enum):
"""Short theme clips to play as a WAV file."""
MARIO = 'mario'
"""Mario completes a level."""
TETRIS = 'tetris'
"""The Tetris theme."""
CONTRA = 'contra'
"""The Jungle theme from Contra."""
[docs]
class Song:
def __init__(self,
sample_rate: float = 44100,
tempo: float = 100) -> None:
"""Create a song.
Args:
sample_rate: The sample rate, in Hz.
tempo: The tempo (beats per minute). This parameter creates
class-instance constants that can be useful when adding notes
and rests.
"""
self._sample_rate = sample_rate
self._frames = []
self.QUARTER = 60 / tempo # in seconds
self.HALF = 2 * self.QUARTER
self.WHOLE = 2 * self.HALF
self.EIGHTH = self.QUARTER / 2
self.SIXTEENTH = self.EIGHTH / 2
self.DOTTED_WHOLE = 1.5 * self.WHOLE
self.DOTTED_HALF = 1.5 * self.HALF
self.DOTTED_QUARTER = 1.5 * self.QUARTER
self.DOTTED_EIGHTH = 1.5 * self.EIGHTH
self.DOTTED_SIXTEENTH = 1.5 * self.SIXTEENTH
[docs]
def add(self,
duration: float,
left: str | list[str] = None,
right: str | list[str] = None,
volume: float = 0.5) -> None:
"""Add a rest, note or chord to the song.
Args:
duration: The number of seconds the note will play for, or, the
number of seconds to rest.
left: The name(s) of the note(s) to add to the left channel
(e.g., 'A4' or ['C4', 'G3', 'E3'] for a chord). Do not specify
a value to add a rest.
right: The name(s) of the note(s) to add to the right channel
(e.g., 'C3'). If not specified then equals the left channel.
volume: The amplitude of the sine wave. Should be between 0 and 1.
"""
n = round(self._sample_rate * duration)
left_channel = np.zeros(n)
right_channel = np.zeros(n)
if left:
t = np.linspace(0, duration, n)
if isinstance(left, str):
left = [left]
amplitude = volume / len(left)
for note in left:
left_channel += amplitude * np.cos(2 * np.pi * NOTES[note] * t)
if right is None:
right_channel = left_channel
else:
if isinstance(right, str):
right = [right]
amplitude = volume / len(right)
for note in right:
right_channel += amplitude * np.cos(2 * np.pi * NOTES[note] * t)
channels = np.vstack((left_channel, right_channel)).T
self._frames.append(channels)
[docs]
def play(self) -> None:
"""Play the song."""
with BytesIO() as buffer:
with wave.open(buffer, mode='w') as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(self._sample_rate)
for frame in self._frames:
# convert to (little-endian) 16-bit integer
audio = (frame * (2 ** 15 - 1)).astype('<h')
w.writeframes(audio.tobytes())
try:
winsound.PlaySound(buffer.getvalue(), winsound.SND_MEMORY)
except RuntimeError as e:
logger.error(e)
[docs]
def play(wav: str | Theme, wait: bool = True) -> None:
"""Play a WAV file or theme.
Args:
wav: A file or theme to play.
wait: Whether to wait for the WAV file to finish playing before returning.
Only used if `wav` is a file. Specifying a :class:`.Theme` will
always wait, since the audio data is stored in memory.
"""
if wav == Theme.MARIO:
song = Song(tempo=400)
song.add(song.QUARTER, 'C3')
song.add(song.QUARTER, 'C4')
song.add(song.QUARTER, 'E4')
song.add(song.QUARTER, 'G4')
song.add(song.QUARTER, 'C5')
song.add(song.QUARTER, 'E5')
song.add(song.HALF, 'G5')
song.add(song.HALF, 'E5')
song.add(song.QUARTER, 'D3')
song.add(song.QUARTER, 'C4')
song.add(song.QUARTER, 'D#4')
song.add(song.QUARTER, 'G#4')
song.add(song.QUARTER, 'C5')
song.add(song.QUARTER, 'D#5')
song.add(song.HALF, 'G#5')
song.add(song.HALF, 'D#5')
song.add(song.QUARTER, 'D#3')
song.add(song.QUARTER, 'D4')
song.add(song.QUARTER, 'F4')
song.add(song.QUARTER, 'A#4')
song.add(song.QUARTER, 'D5')
song.add(song.QUARTER, 'F5')
song.add(song.HALF, 'A#5')
song.add(song.QUARTER, 'A#5')
song.add(song.QUARTER, 'A#5')
song.add(song.QUARTER, 'A#5')
song.add(song.WHOLE, 'C6')
song.play()
elif wav == Theme.TETRIS:
song = Song(tempo=160)
song.add(song.QUARTER, 'E5', 'E4')
song.add(song.EIGHTH, 'B4', 'B3')
song.add(song.EIGHTH, 'C5', 'C4')
song.add(song.QUARTER, 'D5', 'D4')
song.add(song.EIGHTH, 'C5', 'C4')
song.add(song.EIGHTH, 'B4', 'B3')
song.add(song.QUARTER, 'A4', 'A3')
song.add(song.EIGHTH, 'A4', 'A3')
song.add(song.EIGHTH, 'C5', 'C4')
song.add(song.QUARTER, 'E5', 'E4')
song.add(song.EIGHTH, 'D5', 'D4')
song.add(song.EIGHTH, 'C5', 'C4')
song.add(song.QUARTER, 'B4', 'B3')
song.add(song.EIGHTH, 'B4', 'B3')
song.add(song.EIGHTH, 'C5', 'C4')
song.add(song.QUARTER, 'D5', 'D4')
song.add(song.QUARTER, 'E5', 'E4')
song.add(song.QUARTER, 'C5', 'C4')
song.add(song.QUARTER, 'A4', 'A3')
song.add(song.QUARTER, 'A4', 'A3')
song.play()
elif wav == Theme.CONTRA:
song = Song(tempo=150)
song.add(song.SIXTEENTH, 'F5')
song.add(song.SIXTEENTH, 'Eb5')
song.add(song.SIXTEENTH, 'C5')
song.add(song.SIXTEENTH, 'Bb4')
song.add(song.SIXTEENTH, 'C5')
song.add(song.SIXTEENTH, 'B4')
song.add(song.SIXTEENTH, 'Ab4')
song.add(song.SIXTEENTH, 'G4')
song.add(song.SIXTEENTH, 'A4')
song.add(song.SIXTEENTH, 'G4')
song.add(song.SIXTEENTH, 'F4')
song.add(song.SIXTEENTH, 'Eb4')
song.add(song.SIXTEENTH, 'F4')
song.add(song.SIXTEENTH, 'Bb3')
song.add(song.SIXTEENTH, 'C4')
song.add(song.SIXTEENTH, 'E4')
song.add(song.HALF, 'F4')
song.add(song.SIXTEENTH)
song.add(song.SIXTEENTH, 'C5')
song.add(song.SIXTEENTH, 'Bb4')
song.add(song.SIXTEENTH, 'C5')
song.add(song.SIXTEENTH, 'D5')
song.add(song.HALF, 'Eb5')
song.play()
else:
# assume it's a file
flags = winsound.SND_FILENAME
if not wait:
flags |= winsound.SND_ASYNC
winsound.PlaySound(wav, flags)
[docs]
def random() -> None:
"""Play a random :class:`.Theme`."""
play(choice(list(Theme.__members__.values())))