An analogue stopwatch with music for game shows

For a party at work with our colleagues, we needed a little stop watch to do a game show. We projected the little thing below onto a big canvas, and the party was big fun:) The stop watch runs with music and is heavily borrowed from this wiki, therefore I decided to contribute back.

Put the music as a set of MP3 files in a folder music/ in the same directory as the script. The following key bindings are defined:

  • ESC: Toggle fullscreen mode
  • Space: Start/Stop watch
  • Return: Reset watch
  • M: Toggle music. The music will start/stop together with the watch, and smoothly fade in and out

One can wrap the script and music into a starpack, and thanks to snack, this also plays directly from a single file.

#!/usr/bin/wish
set basedir [file normalize [file dirname [info script]]]

lappend auto_path [file join $basedir lib]
package require snit
package require snack
package require Tk

snit::widgetadaptor flexiclock {

        option -bgcolor -default {#909090} -configuremethod Configured
        option -fgcolor -default white -configuremethod Configured
        option -inner_div -default 10 -configuremethod Configured
        option -outer_div -default 60 -configuremethod Configured
        option -inner_every -default 1 -configuremethod Configured
        option -outer_every -default 1 -configuremethod Configured
        
        option -ihandpos -default 0 -configuremethod Configured
        option -ohandpos -default 0 -configuremethod Configured
        
        option -borderfrac -default 0.05 -configuremethod Configured
        option -font -default Helvetica -configuremethod Configured
        
        option -otik1 -default 0.85  -configuremethod Configured
        option -otik2 -default 0.9   -configuremethod Configured
        
        option -itik1 -default 0.75  -configuremethod Configured
        option -itik2 -default 0.8   -configuremethod Configured
        
        option -onpos -default 0.95 -configuremethod Configured
        option -ofnt_size -default 0.05 -configuremethod Configured
        option -ofigcolor -default black -configuremethod Configured
        
        option -inpos -default 0.71 -configuremethod Configured
        option -ifnt_size -default 0.04 -configuremethod Configured
        option -ifigcolor -default grey50 -configuremethod Configured
        
        option -ohandlength -default 0.83 -configuremethod Configured
        option -ohandthickness -default 0.05 -configuremethod Configured
        option -ohandcolor -default black -configuremethod Configured
        
        option -ihandlength -default 0.6 -configuremethod Configured
        option -ihandthickness -default 0.04 -configuremethod Configured
        option -ihandcolor -default red -configuremethod Configured
        
        option -shadowcolor -default grey70 -configuremethod Configured
        option -sdist -default 0.02 -configuremethod Configured


        variable xsize
        variable ysize
        variable radius
        variable xm
        variable ym

        variable redrawid
        variable dirty [dict create hands 0 face 0]

        constructor {args} {
                # create nice clock of size width x width
                # inner division 10
                # outer division 60
                installhull using canvas -width 400 -height 400 -highlightthickness 0
                $hull create rectangle 0 0  400 400 -fill $options(-bgcolor) -tag clockbg
                $hull create oval 10 10 380 380 -fill $options(-fgcolor) -outline black -tag clockfg

                # create hands and shadow
                $hull create line {210 210 210 20} -tag ohandshadow -fill $options(-shadowcolor)
                $hull create line {200 200 200 10} -tag ohand
                
                $hull create line {210 210 390 210} -tag ihand
                $hull create line {200 200 380 200} -tag ihandshadow -fill $options(-shadowcolor)

                $self configurelist $args
        #        $self invalidate face

                bind $win <Configure> [mymethod resize]
        }

        method invalidate {what} {
                dict set dirty $what 1
                if {![info exists redrawid]} {
                        set redrawid [after idle [mymethod redraw]]
                }
        }

        method redraw {} {
                if {[info exists redrawid]} { unset redrawid }
                
                if {[dict get $dirty face]} {
                        $self updateface
                }

                if {[dict get $dirty hands]} {
                        $self updatehands
                }
        }

        method resize {} {
                $self invalidate face
        }

        method Configured {option value} {
                set options($option) $value
                if {[dict exists { 
                        -ihandpos 0 -ihandlength 0 -ihandcolor 0 -ihandthickness 0 
                        -ohandpos 0 -ohandlength 0 -ohandcolor 0 -ohandthickness 0} $option]} {
                                
                        $self invalidate hands
                } else {
                        $self invalidate face
                }
        }

        method updateface {} {
                set xsize [winfo width $win]
                set ysize [winfo height $win]
                set size [expr {min($xsize, $ysize)}]
                set radius [expr {0.5*$size*(1.0-$options(-borderfrac))}]

                set xm [expr {$xsize/2}]
                set ym [expr {$ysize/2}]
                
                # move background
                $hull coords clockbg 0 0 $xsize $ysize
                $hull itemconfigure clockbg -fill $options(-bgcolor)

                # move foreground
                $hull coords clockfg [expr {$xm-$radius}] [expr {$ym-$radius}] [expr {$xm+$radius}] [expr {$ym+$radius}]
                $hull itemconfigure clockfg -fill $options(-fgcolor)


                # redraw figures
                $hull delete iticks
                $hull delete oticks
                $hull delete innerfigures
                $hull delete outerfigures

                set pi 3.14159265358979

                for {set i 1} {$i <= $options(-outer_div)} {incr i} {
                        set np [expr {double($i) / $options(-outer_div)}]
                
                        #draw the outer ticks
                        set x1 [expr {$xm + ($radius * $options(-otik1)) * sin($np * $pi * 2)}]
                        set y1 [expr {$ym - ($radius * $options(-otik1)) * cos($np * $pi * 2)}]
                        set x2 [expr {$xm + ($radius * $options(-otik2)) * sin($np * $pi * 2)}]
                        set y2 [expr {$ym - ($radius * $options(-otik2)) * cos($np * $pi * 2)}]
                        $hull create line [list $x1 $y1 $x2 $y2] -tag oticks -width 2 -fill $options(-ofigcolor)

                        if {$i % $options(-outer_every) == 0} {
                                #draw outer set of numbers
                                set x1 [expr $xm + ($radius * $options(-onpos)) * sin($np * $pi * 2)]
                                set y1 [expr $ym - ($radius * $options(-onpos)) * cos($np * $pi * 2)]
                                $hull create text $x1 $y1 -text $i -tags outerfigures \
                                        -fill $options(-ofigcolor) \
                                        -font "$options(-font) [expr {-round($options(-ofnt_size) * $radius)}]"
                        }

                }

                for {set i 1} {$i <= $options(-inner_div)} {incr i} {
                        set np [expr {double($i) / $options(-inner_div)}]
                
                        #draw the inner ticks
                        set x1 [expr {$xm + ($radius * $options(-itik1)) * sin($np * $pi * 2)}]
                        set y1 [expr {$ym - ($radius * $options(-itik1)) * cos($np * $pi * 2)}]
                        set x2 [expr {$xm + ($radius * $options(-itik2)) * sin($np * $pi * 2)}]
                        set y2 [expr {$ym - ($radius * $options(-itik2)) * cos($np * $pi * 2)}]
                        $hull create line [list $x1 $y1 $x2 $y2] -tag iticks -fill $options(-ifigcolor)
                        if {$i % $options(-inner_every) == 0} {
                                #draw inner set of numbers
                                set x1 [expr $xm + ($radius * $options(-inpos)) * sin($np * $pi * 2)]
                                set y1 [expr $ym - ($radius * $options(-inpos)) * cos($np * $pi * 2)]
                                $hull create text $x1 $y1 -text $i -tags innerfigures \
                                        -fill $options(-ifigcolor) \
                                        -font "$options(-font) [expr {-round($options(-ifnt_size) * $radius)}]"
                        }

                }

                $hull raise ihandshadow
                $hull raise ohandshadow
                
                $hull raise ihand
                $hull raise ohand
                dict set dirty face 0
                dict set dirty hands 1
        }

        method getc {} { return $hull }

        method updatehands {} {
                # configure hands to be at correct positions
                set pi 3.14159265358979
                set np [expr {double($options(-ihandpos)) / $options(-inner_div)}]
                
                set x2 [expr {$xm + ($radius * $options(-ihandlength)) * sin($np * $pi * 2)}]
                set y2 [expr {$ym - ($radius * $options(-ihandlength)) * cos($np * $pi * 2)}]
                
                # shadow distance
                set sd [expr {$radius*$options(-sdist)}]

                $hull coords ihand [list $xm $ym $x2 $y2]
                $hull coords ihandshadow [list [expr {$xm+$sd}] [expr {$ym+$sd}] [expr {$x2+$sd}] [expr {$y2+$sd}]]
                $hull itemconfigure ihand -width [expr {$options(-ihandthickness)*$radius}] -fill $options(-ihandcolor)
                $hull itemconfigure ihandshadow -width [expr {$options(-ihandthickness)*$radius}]

                set np [expr {double($options(-ohandpos)) / $options(-outer_div)}]
                
                set x2 [expr {$xm + ($radius * $options(-ohandlength)) * sin($np * $pi * 2)}]
                set y2 [expr {$ym - ($radius * $options(-ohandlength)) * cos($np * $pi * 2)}]
                
                # shadow distance
                set sd [expr {$radius*$options(-sdist)}]

                $hull coords ohand [list $xm $ym $x2 $y2]
                $hull coords ohandshadow [list [expr {$xm+$sd}] [expr {$ym+$sd}] [expr {$x2+$sd}] [expr {$y2+$sd}]]
                $hull itemconfigure ohand -width [expr {$options(-ohandthickness)*$radius}] -fill $options(-ohandcolor)
                $hull itemconfigure ohandshadow -width [expr {$options(-ohandthickness)*$radius}]
                
                dict set dirty hands 0
        }

}

proc init {} {
        variable w
        variable basedir
        variable fadetime 2000

        set w(clock) [flexiclock .c \
                -outer_every 5 -ofnt_size 0.1 -onpos 0.92 \
                -otik1 0.8 -otik2 0.85 \
                -ifnt_size 0.06 -inpos 0.65 \
                -itik1 0.7 -itik2 0.75]
        set w(disp) [label .l -text "00:00.00" -textvariable digitime -font "Helvetica -20" -bg black -fg red]
        bind $w(disp) <Configure> resizetime
        place $w(disp) -relx 0 -rely 0 -relwidth 1 -relheight 0.2
        place $w(clock) -relx 0 -rely 0.2 -relwidth 1 -relheight 0.8

        bind . <space> startstop
        bind . <Return> reset
        bind . <Escape> switchfullscreen
        bind . <m> switchmusic

        variable splittime 0
        variable running 0
        showtime

        # read music files 
        variable musicfiles [glob [file join $basedir music *.mp3]]
        variable musicindex 0
        readmusic
        # create fading filters
        variable ifade [snack::filter fade in logarithmic $fadetime]
        variable ofade [snack::filter fade out exponential $fadetime]
        
}

proc switchfullscreen {} {
        if {[wm attributes . -fullscreen]} {
                wm attributes . -fullscreen 0
        } else {
                wm attributes . -fullscreen 1
        }
}


proc resizetime {} {
        variable w
        set pt [winfo height $w(disp)]
        $w(disp) configure -font "Helvetica [expr {-round($pt*0.75)}]"
}

proc startstop {} {
        variable running
        variable splittime
        variable starttime
        set now [clock clicks -milliseconds]

        if {$running} {
                incr splittime [expr {$now-$starttime}]
                set running false
                stopmusic
        } else {
                set starttime $now
                set running true
                startmusic
        }
        showtime
}

proc reset {} {
        variable running
        if {!$running} {
                variable splittime 0
                showtime
        }
}


proc showtime {} {
        variable starttime
        variable running
        variable splittime
        variable afterid

        variable w
        variable digitime
        variable musicon

        if {[info exists afterid]} { 
                after cancel $afterid 
                unset afterid
        }

        if {$running} {
                set time [expr {$splittime+[clock clicks -milliseconds]-$starttime}]
        } else {
                set time $splittime
        }
        # compute time in minutes, seconds and hundreth
        set ms [expr {$time % 1000}]
        set s [expr {$time / 1000}]

        set min [expr {$s/60}]
        set s [expr {$s % 60}]

        set frac [expr {double($ms/10)*0.1}]

        $w(clock) configure -ihandpos $frac -ohandpos $s
        
        # digital display
        if {$musicon} {
                set fmtstring "\u25c0 %02d:%02d.%02d \u25b6"
        } else {
                set fmtstring "%02d:%02d.%02d"
        }
        set digitime [format $fmtstring $min $s [expr {$ms/10}]]

        if {$running} { 
                set afterid [after 20 showtime]
        }        
}


proc readmusic {} {
        variable musicindex
        variable musicfiles
        if {$musicindex >= [llength $musicfiles]} {
                set musicindex 0
        } 
        
        set mp3 [lindex $musicfiles $musicindex]

        snack::sound player -file $mp3
        
        variable musicposition 0

        incr musicindex
}

proc startmusic {} {
        variable ifade
        variable musicposition
        variable musicon

        if {!$musicon} return

        player play -start $musicposition -filter $ifade -command nextmusic
}

proc stopmusic {} {
        variable ofade
        variable fadetime
        variable musicposition
        variable musicon

        if {!$musicon} return

        lassign [player info] length rate
        set musicposition [player current_position]
        set endpos [expr {$musicposition+round($rate*$fadetime/1000.0)}]
        player stop
        player play -start $musicposition -filter $ofade -end $endpos
        set musicposition $endpos
}

proc nextmusic {} {
        # music stopped. read next file and start
        player destroy
        readmusic
        set musicposition 0
        player play -command nextmusic
}

variable musicon 0
proc switchmusic {} {
        variable musicon
        variable running
        if {$musicon} {
                if {$running} { stopmusic }
                set musicon 0
        } else {
                set musicon 1
                if {$running} { startmusic }
        }
        showtime ;# display the indicator
}

init