0

The following code receives an MP3, writes it to disk and plays it using OMXPlayer. I want to eliminate the need to write the MP3 to disk before playing it.

song = response.content
file = open("temp.mp3", "wb")
file.write(song)
file.close()
response.close()
play_song_subprocess = subprocess.call(['omxplayer', '-o', 'local', '--vol', '-500', 'temp.mp3'])

How can I eliminate the file.write()? I'm looking to do something like this:

song = response.content
play_song_subprocess = subprocess.call(['omxplayer', '-o', 'local', '--vol', '-500', song])

But this causes the following error: embedded null byte

Charles Duffy
  • 280,126
  • 43
  • 390
  • 441
mmclean
  • 111
  • 1
  • 9
  • So, for example: Try `cat file.mp3 | omxplayer -o local --vol -500 /dev/stdin`; if reading from `/dev/stdin` works, then we should be able to write Python that works too. – Charles Duffy Aug 23 '20 at 22:53
  • (The reason for that test is that a FIFO is _non-seekable_: It can only be read once, from beginning to end; one can't skip ahead, rewind, etc; not all software for manipulating media files can operate that way -- sometimes they expect to be able to read through the content in multiple passes, or jump ahead to a footer, or so forth). – Charles Duffy Aug 23 '20 at 22:58
  • (BTW, just to explain the "embedded null byte" error -- the command line consists of a list of C strings. C strings are terminated by the first NUL they contain; thus, you can't put data that contains any NULs inside of a command-line argument. And anyhow -- `omxplayer` is expecting anything in that position to be a _filename_, not to be data to be played; if it tries to open your data as if it were a filename, it's to be expected that you'd just end up with another error later). – Charles Duffy Aug 23 '20 at 23:09
  • @CharlesDuffy Thank you for the comment but subprocess.call() does not always require filesystem operations. For example: subprocess.call(['omxplayer', '-o', 'local', '--vol', '-500', 'http://xxx.xxx.xxx.xxx:xxxx/get_mp3_song_get']) work fine. – mmclean Aug 23 '20 at 23:12
  • How do you think it finds `/usr/bin/omxplayer` without the filesystem? – Charles Duffy Aug 23 '20 at 23:19
  • song needs to be converted to the equivalent of temp.mp3 but without the disk I/O operation. – mmclean Aug 23 '20 at 23:20
  • 1
    Yes, I know what you were getting at. That's why I told you to run the test with `/dev/stdin` [earlier](https://stackoverflow.com/questions/63552681/how-to-play-mp3-using-omxplayer-without-disk-i-o-operation-in-python?noredirect=1#comment112379973_63552681) (and I'm anxiously awaiting a response to that test). But the point is that the original question title was asking for something that's in a pedantic sense impossible, and thus that a clearer title would improve things. – Charles Duffy Aug 23 '20 at 23:20
  • (BTW, if `/dev/stdin` and `/dev/fd/0` and `/proc/self/fd/0` aren't accepted, try using `-` as a placeholder for stdin in the filename position; it's not guaranteed to work, but it is a POSIX-standardized convention). – Charles Duffy Aug 23 '20 at 23:23
  • I don;t understand the purpose of the test. You would just call ```omxplayer -o local --vol 500 temp.mp3``` to play a local file. – mmclean Aug 23 '20 at 23:28
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/220333/discussion-between-charles-duffy-and-mmclean). – Charles Duffy Aug 23 '20 at 23:28

1 Answers1

1

Backstory For Readers

Established in chat and comments:

  • cat temp.mp3 | omxplayer -o local --vol -500 /dev/stdin causes a segfault.
  • omxplayer -o local --vol -500 /dev/fd/3 3< <(cat temp.mp3) works correctly.

Thus, we can pass a MP3's data in... but not on stdin (which omxplayer uses for controls: pausing, early exiting, etc).


Approach 1: Using A Shell For File Descriptor Wrangling

This is equivalent to "Approach 3", but instead of using very new and modern Python functionality to do the FD wrangling in-process, it launches a copy of /bin/sh to do the work (and consequently will work with much older Python releases).

play_from_stdin_sh = '''
exec 3<&0                                     # copy stdin to FD 3
exec </dev/tty || exec </dev/null             # make stdin now be /dev/tty or /dev/null
exec omxplayer -o local --vol -500 /dev/fd/3  # play data from FD 3
'''
p = subprocess.Popen(['sh', '-c', play_from_stdin_sh], stdin=subprocess.POPEN)
p.communicate(song)  # passes data in "song" as stdin to the copy of sh

Because omxplayer expects to use stdin to get instructions from its user, we need to use a different file descriptor for passing in its contents. Thus, while we have the Python interpreter pass content on stdin, we then have a shell copy stdin to FD 3 and replace the original stdin with a handle or either /dev/tty or /dev/null before invoking omxplayer.


Approach 2: Using A Named Pipe

There's a little bit of a question as to whether this is cheating on the "no writing to disk" constraint. It doesn't write any of the MP3 data to disk, but it does create a filesystem object that both processes can open as a way to connect to each other, even though the data written to that object flows directly between the processes, without being written to disk.

import tempfile, os, os.path, shutil, subprocess

fifo_dir = None
try:
  fifo_dir = tempfile.mkdtemp('mp3-fifodir')
  fifo_name = os.path.join(fifo_dir, 'fifo.mp3')
  os.mkfifo(fifo_name)
  # now, we start omxplayer, and tell it to read from the FIFO
  # as long as it opens it in read mode, it should just hang until something opens
  # ...the write side of the FIFO, writes content to it, and then closes it.
  p = subprocess.Popen(['omxplayer', '-o', 'local', '--vol', '-500', fifo_name])
  # this doesn't actually write content to a file on disk! instead, it's written directly
  # ...to the omxplayer process's handle on the other side of the FIFO.
  fifo_fd = open(fifo_name, 'w')
  fifo_fd.write(song)
  fifo_fd.close()
  p.wait()
finally:
  shutil.rmtree(fifo_dir)

Approach 3: Using A preexec_fn In Python

We can implement the file descriptor wrangling that Approach 1 used a shell for in native Python using the Popen object's preexec_fn argument. Consider:

import os, subprocess

def move_stdin():
  os.dup2(0, 3)                       # copy our stdin -- FD 0 -- to FD 3
  try:
    newstdin = open('/dev/tty', 'r')  # open /dev/tty...
    os.dup2(newstdin.fileno(), 0)     # ...and attach it to FD 0.
  except IOError:
    newstdin = open('/dev/null', 'r') # Couldn't do that? Open /dev/null...
    os.dup2(newstdin.fileno(), 0)     # ...and attach it to FD 0.

p = subprocess.Popen(['omxplayer', '-o', 'local', '--vol', '-500', '/dev/fd/3'],
                     stdin=subprocess.PIPE, preexec_fn=move_stdin, pass_fds=[0,1,2,3])
p.communicate(song)
Charles Duffy
  • 280,126
  • 43
  • 390
  • 441