Tuesday, November 12, 2013

Playing a sound (AIFF) file from Python using PySDL2

Sometimes you need to play sounds or music (digitized samples) from Python, which really ought to be a simple task. Yet it took me a little while to work out, and the resulting source code is quite simple, so I figured I'd share it here in case anybody else is struggling with it.

The Python wiki lists quite a few packages for working with audio, but most of them are overkill for basic audio recording and playback.

For quite some time I had been using PyAudio, which adds Python bindings to the PortAudio project. I really like it because it focuses entirely on recording and playing audio. But, for some reason, when I recently upgraded to Mavericks, it stutters whenever I try to play samples at a sample rate lower than 44.1 KHz. I've emailed the author to try to get to the bottom of it.

In the meantime, I tried a new package, PySDL2, which adds Python bindings to the SDL2 (Simple Directmedia Layer) project.

SDL2 does quite a bit more than basic audio, and I didn't dig into any of that yet. I hit one small issue with PySDL2, but the one-line change in the issue fixes it. Here's the resulting code:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import sdl2
import sys
import aifc
import threading

class ReadAIFF:
  def __init__(self, fileName):
    self.a = aifc.open(fileName)
    self.frameUpto = 0
    self.bytesPerFrame = self.a.getnchannels() * self.a.getsampwidth()
    self.numFrames = self.a.getnframes()
    self.done = threading.Event()
    
  def playNextChunk(self, unused, buf, bufSize):
    framesInBuffer = bufSize/self.bytesPerFrame
    framesToRead = min(framesInBuffer, self.numFrames-self.frameUpto)

    if self.frameUpto == self.numFrames:
      self.done.set()

    # TODO: is there a faster way to copy the string into the ctypes
    # pointer/array?
    for i, b in enumerate(self.a.readframes(framesToRead)):
      buf[i] = ord(b)

    # Play silence after:
    # TODO: is there a faster way to zero out the array?
    for i in range(self.bytesPerFrame*framesToRead, self.bytesPerFrame*framesInBuffer):
      buf[i] = 0

    self.frameUpto += framesToRead

if sdl2.SDL_Init(sdl2.SDL_INIT_AUDIO) != 0:
  raise RuntimeError('failed to init audio')

p = ReadAIFF(sys.argv[1])
spec = sdl2.SDL_AudioSpec(p.a.getframerate(),
                          sdl2.AUDIO_S16MSB,
                          p.a.getnchannels(),
                          512,
                          sdl2.SDL_AudioCallback(p.playNextChunk))

# TODO: instead of passing None for the 4th arg, I really should pass
# another AudioSpec and then confirm it matched what I asked for:
devID = sdl2.SDL_OpenAudioDevice(None, 0, spec, None, 0)
if devID == 0:
  raise RuntimeError('failed to open audio device')

# Tell audio device to start playing:
sdl2.SDL_PauseAudioDevice(devID, 0)

# Wait until all samples are done playing
p.done.wait()

sdl2.SDL_CloseAudioDevice(devID)


The code is straightforward: it loads an AIFF file, using Python's builtin aifc module, and then creates a callback, playNextChunk which is invoked by PySDL2 when it needs more samples to play. So far it seems to work very well!

2 comments:

  1. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. Hi Mike,

      You need the patch that I put on the "one small issue" (look at the link above), or, you can upgrade to the latest (not yet released I think) PySDL2 which has fixed this issue. Finally, you can use ctypes to do the cast (I haven't tried this but it should work).

      Delete