Search code examples
pythonpygame

Unable to properly capture mp3 stream frames


I can only get streams to play for between 1 and 10ish frames before the player crashes. I've noticed that every time the player crashes, it has far fewer bytes to work with for that frame. This means there is something wrong with my sync logic. It's finding a sync too early. I don't know how to solve this. I've done some research on the mp3 specs and found examples in forms that are completely unrelated to Python. They seem to agree to do a word_search in the 12 most significant bits. I've done that, and it even works ... for less than a second. What am I missing?

This is the entire code. z is tossed in to make sure the loop doesn't run on forever. It's not intended to be permanent.

import requests, io, pygame

pygame.init()
pygame.mixer.init()

def stream(url:str):
    s = requests.Session()
    d = bytearray()
    z = 0
    
    with s.get(url, headers=None, stream=True) as resp:
        for b in resp.iter_content():
        
            d.append(int.from_bytes(b))   #append this byte
            sync = int.from_bytes(d[-2:]) #get last two bytes
            
            #if we have enough bytes and sync
            if (len(d) > 12) and (sync == 0xFFF0):
                pygame.mixer.music.load(io.BytesIO(bytes(d)))
                pygame.mixer.music.play()
                pygame.event.wait()
                    
                d = bytearray() #clear for next frame
                z+=1
                
            if z>100: return
                
stream('http://198.27.120.235:8450')

edit

I grabbed the header and inserted it at the beginning of each new frame. It works for longer, but it seems to just quit after a few seconds. It teeters between no error and pygame.error: music_drmp3: corrupt mp3 file (bad stream). How do I reliably "chunk" the mp3 data?

import requests, io, pygame

pygame.init()
pygame.mixer.init()

def stream(url:str):
    s = requests.Session()
    h = bytearray()
    d = bytearray()
    
    with s.get(url, headers=None, stream=True) as resp:
        for b in resp.iter_content():
            if len(h) < 4: h.append(int.from_bytes(b))   #get header
            else         : d.append(int.from_bytes(b))   #append this byte
            
            sync = int.from_bytes(d[-2:])                #get last two bytes
            
            #if we have enough bytes and sync
            if (L:=len(d)) and (sync == 0xFFF0):
                #length, first 4 bytes, last 4 bytes (looking for patterns)
                print(L, hex(int.from_bytes(d[:4])), hex(int.from_bytes(d[-4:])))
                
                pygame.mixer.music.load(io.BytesIO(h+d))
                pygame.mixer.music.play()
                d = bytearray()
                
stream('http://198.27.120.235:8450')

As you can see from a print, the size of the data (left column) fluctuates wildly, but all of those lines, up to the last one, played just fine. However, the last one is always significantly less bytes than the rest of the frames. This can only mean that I am not finding the sync bits properly. Apparently, the signature of the sync bits can exist in the audio data. How do I differentiate between the 2?

7175 0x7f0a747d 0x83fffff0
20035 0x41dffe4d 0xfffffff0
1114 0xcde3fff3 0x1016fff0
5039 0x7f29ff68 0x7ffffff0
9337 0x28358c14 0x71dffff0
2262 0x8bc3caa1 0xfffffff0
18097 0x8894f5a0 0x659ffff0
19282 0xd09ababd 0xfffffff0
8136 0xe75fa462 0xfc41fff0
1336 0xc7ffa8f9 0x77fffff0
485 0x683a5b88 0xcbbbfff0
Traceback (most recent call last):
  File "C:\Users\Michael\Documents\SlimPad_ftk\scratch.py", line 25, in <module>
    stream('http://198.27.120.235:8450')
  File "C:\Users\Michael\Documents\SlimPad_ftk\scratch.py", line 21, in stream
    pygame.mixer.music.load(io.BytesIO(h+d))
pygame.error: music_drmp3: corrupt mp3 file (bad stream).

Solution

  • I did not realize that play is non-blocking. The solution was to take more control over when a frame is loaded. Also, stream data needs to be an even number of bytes when comparing sync.

    This refactor works flawlessly with one caveat, loading the next frame is somewhat determined by how fast the loop can catch it. In those microseconds where we are in a lull, a small 'click' is created. Refactoring this again where play is waiting for itself (instead of the loop), should eliminate the clicks. However, the clicks and pops are far less than you would hear on an old record, and quite similar in dynamics. I could just not refactor anything and call it a feature.

    "Listen to all of today's hottest streams in record player quality!"

    import requests, io, os, pygame, time
    
    #create and call - clear terminal
    (clear := lambda: os.system(('clear','cls')[os.name=='nt']))()
    
    pygame.init()
    pygame.mixer.init()
    
    def streamer(rip:bool=False) -> None:
        sess   = requests.Session()
        head   = bytearray()
        frame  = bytearray()
        stream = bytearray()
        sync   = b'\xff\xf0'
        
        filename = f'episode_{int(time.time()*100000)}.mp3'
        
        #create file if we need it and it doesn't exist
        if rip and not os.path.isfile(filename):
            with open(filename, 'w'): ...
        
        try:
            url = input("\nstream address: http://")
            
            with sess.get(f'http://{url}', headers=None, stream=True) as resp:
                #fail harder - so we can catch it and reset
                if not resp.ok: raise Exception
                
                for byte in resp.iter_content():
                    if stream and not pygame.mixer.music.get_busy():
                        #play stream or fail
                        try:
                            pygame.mixer.music.load(io.BytesIO(head + stream + sync))
                            pygame.mixer.music.play()
                        except: 
                            print(f'ignoring corrupt stream')
                        
                        #append stream to file
                        if rip:
                            with open(filename, 'r+b') as file:
                                file.seek(4, 2)
                                file.write(stream+sync)
                        
                        #clear stream
                        stream = bytearray()
                    
                    #header and current frame
                    if len(head) == 4: frame.extend(byte)
                    else             : head.extend(byte) 
                    
                    #must have an even number of bytes to compare sync                
                    if (L := len(frame)) % 2: continue
                    
                    #compare sync frame
                    if L and (frame[-2:] == sync):
                        stream.extend(frame[:-2]) #append to stream
                        frame = bytearray()       #clear current frame
        except: 
            clear()
            print('failures have occured. maybe try a different stream address')
            streamer(rip)
    
    #test streams 
    #kathy.torontocast.com:3330
    #www.partyviberadio.com:8020
    #198.27.120.235:8450   
        
    streamer(True)