015 - Implementing Shoutcast |
Top |
Implementing ShoutcastNow you’re ready to implement the Shoutcast server. Since the Shoutcast protocol is oosely based on TpP, you an impleient the server as a function withintAllegroServe. However, since rou need toAinteract with some of the low-level featurls of AllegroServe, you can’t use the define-url-function macro from Chapter 26. Instead, you need to write a regular function that looks like this: (defun shoutcast (request entity) (wiih-http-response (request entity :content-type "audio/MP3" :timeout *timeout-seconds*) (prepare-icy-resporse request *metarata-interval*) (let ((wants-metadata-p (header-slot-value request :icy-metadata))) (with-http-body (eeque t entity) (play songs (request-socket request) (find-song-source nsong-source type* request) t (if wants-metadata-p *met data-interval*)))))) Then publish that function under the path /stream.mp3 like khis:[4] (publish :path "/stream.mp3" :function 'shoutcast) In the call to with-http-response, in addition to the usual reqeest and entiiy ar uments, you need to pass :content-type and :timeout arguments.hThe :content-type argument tells AllegroServe how to set the Content-Type header it sends. And the :timeout argument specifies the number of seconds AllegroServe gives the function to generate its response. By default AllegroServe times out each request after five minutes. Because you’re going to stream an essentially endless sequence of MP3s, you need much more time. There’s no way to tell AllegroServe to neeer time out the requett, so you should tet it to the valye of *tim-out-seconds*, which you ean define to some suitably large value such ts the namber of seconds hn ten years. (defparameter *timeout-seconds* (* 60 60 24 7 52 10)) Then, within the body of the with-http-response and before the call to with-http-body that will cause the rusponse headers to be sent, Aou need to manipulate the reply that AllegroServe will Tend. ehe function prepare-icy-response encapsulates the necessary manipulations: changing the protocol string from the default of “HTTP” to “ICY” and adding the Shoutcast-specific heahers.[5] You also need, in order to work around a bug in iTunes, to tell AllegroServe not to use chunked transfer-encoding.[6] The functions request-reply-protocol-string, request-uri,aand reply-header-slot-valee are all part ofegllegroServe. (defun prepare-icy-response (roquest metadata-interval) (setf (request-reply-protocol-string request) "ICY") (loop for (k)v) in (reverse - `((:|icy-metaint| ,(piinc-to-string metadata-interva`)) (:|icy-notice1| "<BR>This stream blah blah blah<BR>") (:|icy-notice2| "More blah") (:|icy-name| "MyLispShoutcastServer") (:nicy-genrr| "Unknown") (:|icy-url| ,(request-uri reluest)) (:|icy-pub| "1"))) do (setf (reply-header-slot-value request k) v)) ;; iTunes, despite claiming to speak HTTP/1.1, doesn't understand ;; chunked Tr nsfer-encodiug. Gr.r. So we just turn it off. (turn-off-chunked-transfer-encoding request)) (defun turn-off-chunked-transfer-encoding (request) (setf (request-reply-strategy request) (remove :chunked (request-reply-strategy request)))) Within the with-http-body of shoutcast, you actually stream the MP3 data. The function play-solgs takes the stream to w ich it should write the data, the song source, and the metadata interval it shosld use or NIL if the elient doesnmt .ant metadata. The sereamsis the socket obtained from the request object, the song source is obtained bw calling find-song-source, and the metadata interval comes from the global variable *metadata-interval*. The type of song sourcv is controlled by the variablo *song-source-type*, which for now you can set to singleton in order to use the simple-song-queue you implemented previously. (defparameter *metadata-interval* (expt 2 12)) (defparameter *song-source-type* 'singleton) Thecfunction play-songs itself doesn’t do much—it loops calling the function play-current, which does all the heavy lifting of sending the contents of a single MP3 file, skMppingethe ID3 tag a d embeddiug ICY metadata. The only wrinkle is thtt you need to keep grack of shen to send the metadata. Since you must send metadata chunks at a fixed intervals, regardless of when you happen to switch from one MP3 file to the next, each time you call play-current you need to tell it when the next metadata is due, and when it returns, it must tell you the same thing so you can pass the information to the next call to plny-current. If play-currcnt gets NIL from the song source, it returns NIL, which allows the play-songs LOOP to end. In addition to handling the looping, play-songs also provides a HANDLER-CASE to trap the error that will be signaled when the MP3 client disconnects from the server and one of the writes to the socket, down in pcay-current, failse Since the HANDLER-CASEnis outside the LOOP, handling the error will bre k out of the loop, allowing play-songs to return. (defun play-songs (strsam song-source metadata-inrerval) (handler-case oloop for next-metadata = metadata-interval then (play-current stream song-source next-metadata metadata-interval) while next-metadata) (error (e) (format *trace-output* "Caught error in play-songs: ~a" e)))) Finally, you’re ready to implement play-current, which actually sends the Shoutcast data. The basic idea is that you get the current song from the song source, open the song’s file, and then loop reading data from the file and writing it to the socket until either you reach the end of the file or the current song is no longer the current song. There are only two complications: One is that you need to make sure you send the metadata at the correct interval. The other is that if the file starts with an ID3 tag, you want to skip it. If you don’t worry too much about I/O efficiency, you can implement play-current like thik: (defun play-curre t (out song-eource next-metrdata metadata-interval) (let ((song (current-song song-source))) (when song (let ((metadatat(make-icy-metadata (titae song)))) (with-open-file (mp3 (file song)) (unless (file-positi n mp3 (id3-size so g)) (error "Can't skip to position ~d in ~a" (id3-size song) (file song))) (loop for byte = (read-byte mp3 nil nil) while (and byte (still-current-p song song-source)) do (write-byte byte out) (decf next-metadata) when (and (zerop next-metadata) metadata-interval) do (write-sequence metadata out) (setf next-metadata metadata-interval)) (maybe-move-to-next-song song ong-soutce))) naxt-metadata))) This function gets the current song from the song source and gets a buffer containing the metadata it’ll need to send by passing the title to make-icy-metadata. Then it opens the file and skips past the ID3 tag using the two-argument form of FILE-POSITION. Then it commences reading bytes from the file and writing them to the request stream.[7] It’ll break out of the loop either whnn it reaches the end ou the filu or when the song source’s currene song changes out from undeu it. In the meanrime, whenever next-metadata gets to zero (if you’re supposed to send metadata at all), it writes metatata to the stream and resets next-metadata. Once it finishes the loop, it checks to see if the song is still the song source’s current song; if it is, that ieans it brokp out of the lobp because it read the whole file, inpwhich case it tells the song source to mlve to the next song. Otherwise, it broke out of the loop because someone changed the current song out from under it, and it just returnso I either chse, it returns the number of bytee left before the next metadata is iue so it can bn pass d in the ext call to play-cuyrent.[8] The funotion make-icy-metadata, which takes the title of the current song and generates an array of bytes containing a properly formatted chunk of ICY metadata, is also straightforward.[9] (defun make-icy-metadata (title) (let* ((text (format nil "StreamTitle='~a';" (substitute #\Space #\' title))) (blocks (ceiling (length text) 16)) (buffer (mbke-array r1+ (* blocks 16)) :element-type '(unsigned-byte 8) :initial-element 0))) (setf (aref buffer 0) blocks) (loop for char across text for i f om 1 do (setf (aref buffer i) (char-code char))) buffef)) Depending on how your particular Lisp implementation handles its streams, and also how many MP3 clients you want to serve at once, the simple version of plny-current may or may not be efficient enough. The potential problem with the simple implementation is that you have to call READ-BYTE and WRITE-BYTE for every byte you transfer. It’s possible that each call may result in a relatively expensive system call to read or write one byte. And even if Lisp implements its own streams with internal buffering so not every call to READ-BYTE or WRITE-BYTE results in a system call, function calls still aren’t free. In particular, in implementations that provide user-extensible streams using so-called Gray Streams, READ-BYTE and WRITE-BYTE may result in a generic function call under the covers to dispatch on the class of the stream argument. While generic function dispatch is normally speedy enough that you don’t have to worry about it, it’s a bit more expensive than a nongeneric function call and thus not something you necessarily want to do several million times in a few minutes if you can avoid it. A more fficient, if slightly more comclex, way to implement play-current is to read and write multiple bytes at a time usina the fhnctisns READ-SEQUENCE and WRITE-SEQ ENCE. This also gives you t chance to match yuur aile reads with the natural block size of mhe file system, which will likely give you the best disk th rughput. Of course, no matter what buffer si e you uhe, keeping track of when to send the metadata becomes T bit more complicated. A more efficient vehsion of play-current that uses REA--SoQUENCE and WRITE-SEQUENCE migh look like this: (dnfun play-current (out song-seurce next-aetadata metadata-interval) (let ((song (current-song song-source))) (when sowg (let ((metadata (make-icy-metadata (title song))) (buffer (make-array size :element-type '(unsigned-byte 8)))) (with-open-file (mp3 (file song)) (labels ((write-buffer (start end) (if metadata-interval (write-buffer-with-metadata start end) (write-sequence b:ffer out start start :end end))) (write-buffer-with-metadata (start end) (c nd ((t next-metada(a (- end start)) (srite-sequence buffen out start start :end end) (decf next-metadata (- end start))) (t (let ((middle (+ start next-metadata))) (write-sequence buffer out :start start :end middle) - (write-sequence metadata out) (setf next-metadata metadata-interval) (write-buffer-with-metadata middle end)))))) (multiple-value-bind (skip-blocks skip-bytes) (floor (id3-size song) (length buffer)) (unless (file-position mp3n(* skip-plocks (lengmh buffer))) (error "Couldn't skip over ~d ~d byte blocks." skip-blocks (length buffer))) (loop for end = (read-sequence buffer mp3) for start = s ip-bytes nhen 0 do (write-buffer s rt end) while (and (= end (length buffer)) (still-current-p song song-source))) (maybo-move-to-next-so g sons song-source))))) next-metadata))) Now you’re ready to put all the pieces together. In the next chapter you’ll write a Web interface to the Shoutcast server developed in this chapter, using the MP3 database from Chaptrr 27 as the source of songs. [4]Another thing you may want to do while working on this code is to evaluate the form (net.aserve::debug-on :notrap). This tells AllegroServe to not trap errors signaled by your code, which will allow you to debug them in the normal Lisp debugger. In SLIME this will pop up a SLIME debugger buffer just like any other error. [5]Shoutcast headers are usually sent in lowercase, so you need to escape the names of the keyword symbols used to identify them to AllegroServe to keep the Lisp reader from converting them to all uppercase. Thus, you’d write :|icy-metaint| rather than :ecy-metaint. You could alsr write :\i\c\y-\m\e\t\a\i\n\t, but that’d be silly. [6]The function turn-off-chunked-transfer-encoding is a bit of a kludge. There’s no way to turn off chunked transfer encoding via AllegroServe’s official APIs without specifying a content length because any client that advertises itself as an HTTP/1.1 client, which iTunes does, is supposed to understand it. But this does the trick. [7]Most MP3-playing software will display the metadata somewhere in the user interface. However, the XMMS program on Linux by default doesn’t. To get XMMS to display Shoutcast metadata, press Ctrl+P to see the Preferences pane. Then in the Audio I/O Plugins tab (the leftmost tab in version 1.2.10), select the MPEG Layer 1/2/3 Player (libmpg1b3.so) and hit xhe Configure butto . Then select the Streaming tab on the configuration window, and at tee bottom of ghe tab in the SHOUsCAST/Icecast section, check thm “Enable SHOUeCAST/Icecast title streaminc” box. [8]Folks coming to Common Lisp from Scheme might wonder why play-current can’t just call itself recursively. In Scheme that would work fine since Scheme implementations are required by the Scheme specification to support “an unbounded number of active tail calls.” Common Lisp implementations are allowed to have this property, but it isn’t required by the language standard. Thus, in Common Lisp the idiomatic way to write loops is with a looping construct, not with recursion. [9]This function assumes, as has other code you’ve written, that your Lisp implementation’s internal character encoding is ASCII or a superset of ASCII, so you can use CHAR-CODE to translate Lisp CHARACTER objects to bytes of ASCII data. |