Search code examples
python-3.xhttppygameurllib

Wrap an io.BufferedIOBase such that it becomes seek-able


I was trying to craft a response to a question about streaming audio from a HTTP server, then play it with PyGame. I had the code mostly complete, but hit an error where the PyGame music functions tried to seek() on the urllib.HTTPResponse object.

According to the urlib docs, the urllib.HTTPResponse object (since v3.5) is an io.BufferedIOBase. I expected this would make the stream seek()able, however it does not.

Is there a way to wrap the io.BufferedIOBase such that it is smart enough to buffer enough data to handle the seek operation?

import pygame
import urllib.request
import io

# Window size
WINDOW_WIDTH  = 400
WINDOW_HEIGHT = 400
# background colour
SKY_BLUE      = (161, 255, 254)

### Begin the streaming of a file
### Return the urlib.HTTPResponse, a file-like-object
def openURL( url ):
    result = None

    try:
        http_response = urllib.request.urlopen( url )
        print( "streamHTTP() - Fetching URL [%s]" % ( http_response.geturl() ) )
        print( "streamHTTP() - Response Status [%d] / [%s]" % ( http_response.status, http_response.reason ) )
        result = http_response
    except:
        print( "streamHTTP() - Error Fetching URL [%s]" % ( url ) )

    return result


### MAIN
pygame.init()
window  = pygame.display.set_mode( ( WINDOW_WIDTH, WINDOW_HEIGHT ) )
pygame.display.set_caption("Music Streamer")


clock = pygame.time.Clock()
done = False
while not done:

    # Handle user-input
    for event in pygame.event.get():
        if ( event.type == pygame.QUIT ):
            done = True
    # Keys
    keys = pygame.key.get_pressed()
    if ( keys[pygame.K_UP] ):
        if ( pygame.mixer.music.get_busy() ):
            print("busy")
        else:
            print("play")
            remote_music = openURL( 'http://127.0.0.1/example.wav' )
            if ( remote_music != None and remote_music.status == 200 ):
                pygame.mixer.music.load( io.BufferedReader( remote_music ) )
                pygame.mixer.music.play()

    # Re-draw the screen
    window.fill( SKY_BLUE )

    # Update the window, but not more than 60fps
    pygame.display.flip()
    clock.tick_busy_loop( 60 )

pygame.quit()

When this code runs, and Up is pushed, it fails with the error:

streamHTTP() - Fetching URL [http://127.0.0.1/example.wav]
streamHTTP() - Response Status [200] / [OK]
io.UnsupportedOperation: seek
io.UnsupportedOperation: File or stream is not seekable.
io.UnsupportedOperation: seek
io.UnsupportedOperation: File or stream is not seekable.
Traceback (most recent call last):
  File "./sound_stream.py", line 57, in <module>
    pygame.mixer.music.load( io.BufferedReader( remote_music ) )
pygame.error: Unknown WAVE format

I also tried re-opening the the io stream, and various other re-implementations of the same sort of thing.


Solution

  • If your fine with using the requests module (which supports streaming) instead of urllib, you could use a wrapper like this:

    class ResponseStream(object):
        def __init__(self, request_iterator):
            self._bytes = BytesIO()
            self._iterator = request_iterator
    
        def _load_all(self):
            self._bytes.seek(0, SEEK_END)
            for chunk in self._iterator:
                self._bytes.write(chunk)
    
        def _load_until(self, goal_position):
            current_position = self._bytes.seek(0, SEEK_END)
            while current_position < goal_position:
                try:
                    current_position = self._bytes.write(next(self._iterator))
                except StopIteration:
                    break
    
        def tell(self):
            return self._bytes.tell()
    
        def read(self, size=None):
            left_off_at = self._bytes.tell()
            if size is None:
                self._load_all()
            else:
                goal_position = left_off_at + size
                self._load_until(goal_position)
    
            self._bytes.seek(left_off_at)
            return self._bytes.read(size)
    
        def seek(self, position, whence=SEEK_SET):
            if whence == SEEK_END:
                self._load_all()
            else:
                self._bytes.seek(position, whence)
    

    Then I guess you can do something like this:

    WINDOW_WIDTH  = 400
    WINDOW_HEIGHT = 400
    SKY_BLUE      = (161, 255, 254)
    URL           = 'http://localhost:8000/example.wav'
    
    pygame.init()
    window  = pygame.display.set_mode( ( WINDOW_WIDTH, WINDOW_HEIGHT ) )
    pygame.display.set_caption("Music Streamer")
    clock = pygame.time.Clock()
    done = False
    font = pygame.font.SysFont(None, 32)
    state = 0
    
    def play_music():
        response = requests.get(URL, stream=True)
        if (response.status_code == 200):
            stream = ResponseStream(response.iter_content(64))
            pygame.mixer.music.load(stream)
            pygame.mixer.music.play()
        else:
            state = 0
    
    while not done:
    
        for event in pygame.event.get():
            if ( event.type == pygame.QUIT ):
                done = True
    
            if event.type == pygame.KEYDOWN and state == 0:
                Thread(target=play_music).start()
                state = 1
    
        window.fill( SKY_BLUE )
        window.blit(font.render(str(pygame.time.get_ticks()), True, (0,0,0)), (32, 32))
        pygame.display.flip()
        clock.tick_busy_loop( 60 )
    
    pygame.quit()
    

    using a Thread to start streaming.

    I'm not sure this works 100%, but give it a try.