Ogg tools

EG I have a portable music player which supports Ogg files. While any modern unix/linux workstation comes with the tools to convert and otherwise handle this format, I want to do it with tcl.

So, after a while digging on the somewhat confusing documentation, I've come with this code to read the Ogg/Vorbis metadata and write comments to a new file (as the 'vorbiscomment' utility do). The readMetaData procedure takes a file name and returns a dictionary with the relevant information about the file (I've used the official documentation names as dictionary keys). The writeComments procedure takes a input filename, an output filename and a dict with key-value pairs of tags. They will be applied to the output file stripping any existing comments the input file already have.

For a list of standard key names see [1 ]


 package require Tcl 8.5

 namespace eval ogg {
        # Relevant documents :
        # http://www.xiph.org/vorbis/doc/Vorbis_I_spec.html
        # http://www.xiph.org/ogg/doc/framing.html
        variable reflected 0
        variable crc_table
 }

 proc ogg::writeComments { oggsource oggdest comments } {
        variable crc_table
        variable reflected

        # as this is needed for only for this proc, put it here
        package require crc16
        if {![info exists crc_table]} {
                set crc_table [::crc::Crc_table 32 0x04c11db7 $reflected]
        }

        set fdin [open $oggsource]
        chan configure $fdin -translation binary -encoding binary

        # verify that's a Ogg/Vorbis file
        if {![IsOgg $fdin]} {
                chan close $fdin
                return -code error "$oggsource is not an Ogg/Vorbis file"
        }
        
        # read the first Ogg page. it's fixed size, and contains the Ogg
        # header plus the Vorbis identification packet
        set oggpage1 [chan read $fdin 58]
        
        # read the number of page segments and the segments table
        set oggp2    [chan read $fdin 22]
        # read 4 bytes of the crc and replace them with zeros
        chan read $fdin 4; append oggp2 \x00\x00\x00\x00
        binary scan  [chan read $fdin 1] cu page_segments
        binary scan  [chan read $fdin $page_segments] cu* segment_table

        # determine the 2nd packet size and read it. read the rest of the data
        # until the end of the second ogg page
        set i        [lsearch -not -integer $segment_table 255]
        set vp2_lv   [lrange $segment_table 0 $i]
        set vp3_lv   [lrange $segment_table $i+1 end]

        set vp2_data [chan read $fdin [expr [join $vp2_lv +]]]
        set vp3_data [chan read $fdin [expr [join $vp3_lv +]]]

        # reuse the vendor string of the file
        binary scan [string range $vp2_data 7 10] iu vs_size
        binary scan [string range $vp2_data 11 10+$vs_size] A* vendor_string

        # build a new 2nd vorbis packet
        set vorbisp2 \x03vorbis
        # add the vendor string
        append vorbisp2 [binary format iuA* $vs_size $vendor_string]
        # hoy many fields of comments we have
        append vorbisp2 [binary format iu [dict size $comments]]
        # now, add the comments
        dict for {key value} $comments {
                set field  "${key}=[encoding convertto utf-8 $value]"
                set size   [string length $field]
                append vorbisp2 [binary format iuA* $size $field]
        }
        # finalize the second Vorbis packet
        append vorbisp2 [binary format c 1]
        # compute its size
        set vp2_size [string length $vorbisp2]
        # determine the new lacing values
        set lvalues [lrepeat [expr {$vp2_size / 255}] 255 ]
        lappend lvalues [expr {$vp2_size % 255}]
        lappend lvalues {*}$vp3_lv

        # reconstruct the second Ogg page. We have already the first 26 bytes
        append oggp2 [binary format cucu* [llength $lvalues] $lvalues]
        append oggp2 $vorbisp2
        append oggp2 $vp3_data

        # we are almost done. calculate the crc32 value as instructed in the
        # specification and put it on the bytes 22-25
        set crc_new  [::crc::Crc $oggp2 32 [namespace current]::crc_table 0 0 $reflected]
        set oggpage2 [string replace $oggp2 22 25 [binary format iu $crc_new]]

        # write the results to the output file
        set fdout [open $oggdest w]
        chan configure $fdout -encoding binary -translation binary
        chan puts -nonewline $fdout $oggpage1
        chan puts -nonewline $fdout $oggpage2

        # the rest of the file is unchanged. simply copy it to the new file
        chan copy $fdin $fdout
        chan close $fdin
        chan close $fdout
 }

 proc ogg::readMetaData { oggfile } {

        set fd [open $oggfile]
        chan configure $fd -translation binary -encoding binary

        # verify that's a Ogg/Vorbis file
        if {![IsOgg $fd]} {
                chan close $fd
                return -code error "$oggfile is not an Ogg/Vorbis file"
        }

        #this will be our returning value: a dict
        set result [dict create]

        # here comes the data of the identification packet
        # extract only the interesting information
            chan seek $fd 35
        binary scan [chan read $fd 21] iucuiuiii \
                version channels sample_r br_max br_nom br_min

        dict set result version $version
        dict set result audio_channels $channels
        dict set result audio_sample_rate $sample_r
        dict set result bitrate_maximum $br_max
        dict set result bitrate_nominal $br_nom
        dict set result bitrate_minimum $br_min
        
        # towards the end of the second Ogg header, this
        # byte counts how many bytes left are in the header
        chan seek $fd 84
        binary scan [chan read $fd 1] c bytesleft
        chan seek $fd [expr {85 + $bytesleft + 7}]

        # follows a vendor string
        binary scan [chan read $fd 4] iu size
        binary scan [chan read $fd $size] A* vendor

        dict set result vendor_string $vendor

        # and now a list of attributes in the form "key=value"
        binary scan [chan read $fd 4] iu numfields
        for {set i 0} {$i < $numfields} {incr i} {
                binary scan [chan read $fd 4] iu size
                binary scan [chan read $fd $size] a* field
                set field [encoding convertfrom utf-8 $field]
                lassign [split $field =] key value
                dict set result user_comment $key $value
        }

        chan close $fd
        return $result
 }

 proc ogg::IsOgg { fd } {
        set pos [chan tell $fd]
        chan seek $fd 0
        set data [chan read $fd 35]
        chan seek $fd $pos
        return [expr {[string range $data 0 3] eq "OggS" \
                && [string range $data 28 34] eq "\x01vorbis" ? 1 : 0}]
 }

 # simple test
 if 0 {
        # ogg::writeComments
        set f [lindex $argv 0]
        set nf [file rootname $f].new.ogg

        puts "Input File:  $f"
        puts "Output File: $nf"
        puts "Tags to apply: title: \"My tune\" artist: \"My favourite artist\""

        ogg::writeComments $f $nf {title "My tune" artist "My favourite artist"}
 }

 if 0 {
        # ogg::readMetaData
        set f [lindex $argv 0]
        puts "File:  $f"
        puts [ogg::readMetaData $f]
 }