Suggestion: A [QUIZ] in the subject of emails about the problem helps everyone on Ruby Talk follow the discussion. Please reply to the original quiz message, if you can.
The aim is to get you from being able to alternate hobbling and brisk walking, to being able to jog for 20 minutes solidly. Over eight weeks you exercise for twenty minutes, three times a week. Over the eight weeks, the ratio of jog to walk steadily increases, and the jogs get longer, while the walks become shorter.
I was explaining to a friend that it's incredibly difficult for me to look at a stop watch and work out in my head if we're supposed to be jogging or walking, how many more jogs we've got to do, and when I can stop and rest. He suggested: 'why not tape yourself giving prompts about when to start and stop'. A brilliant plan. 'Even better, record it on to your phone'. Genius! Except I'm the kind of person who's lazy enough to spend eight times as long writing a program to try to do this for me.
So, the quiz is:
Write a program to create the tracks for each of the eight weeks. Make it give helpful and enthusiastic advice like "you've got to run for another minute / 30 seconds / 15 seconds ...", "walk now for two minutes, you've got three jogs left", "you're on jog 2 of 6", or "well done, that's your last jog. Don't forget to cool down and stretch!"
I just used my Mac's speech synth, and parked my phone near to the speaker on record, in a quiet room (except for the planes every minute heading down to Heathrow). There'd be "bonus points" for actually creating the MP3 directly. Of course, you don't really need to get the computer to speak. It could just print out the messages at the appropriate time.
I thought that this problem was actually a lot more subtle than it seemed on the outside. Getting the coach to put together sensible and varied sentences seemed to be hard. I really wanted to find a much more elegant solution, perhaps some sort of template based approach; so far that has eluded me though. It seems to be something that doesn't easily factor down in to simple and clean functions. I had trouble, at least.
Here's the chunk of code that I have - it's currently only for week 3! You could, of course, write a similar function for the other weeks, but there's _got_ to be a better way than that?
def count_down(s, activity) # Encouragement for the last few seconds (which could get annoying on longer runs?). counts = { 10 => "10 more seconds.", 20 => "20 seconds to go.", 30 => "Half a minute to go.", 60 => "You have 1 more minute of #{activity} left.", 90 => "You have 1 and a half minutes of #{activity} to go."}
# Add in encouragement / prompts for minutes. [2, 3, 4, 6, 8, 10, 12, 15, 20, 25, 30].each {|m| counts[m*60] = "You have #{m} minutes of #{activity} to go."}
# Build an ordered array of the possible lengths of time, and find the index of this # activity's length. times = counts.keys.sort start_index = times.index(s); raise "#{s} is not a known time." unless start_index
# Count down through the time prompts. I bet inject could do this too :) start_index.downto(0) do |i| this_time = times[i] next_time = i>0 ? times[i-1] : 0 delay_to_next = this_time - next_time message = counts[this_time] say message wait delay_to_next end end
def say(to_say) system("say \"#{to_say}\"") end
def wait(s) @wait_until ||= Time.now @wait_until += s while((w = @wait_until - Time.new) > 0) sleep w end end
# For testing it's really helpful to redefine the above to... def say(m); puts m; end def wait(s); puts "Waiting for #{s} seconds."; end
# Code to deal with just week 3! def week_3 wait(0) say "Start your first short run." count_down(90, 'running') say "Stop running now. You have 1 long run and two short ones left." count_down(90, 'walking')
say "Start the first long run now." count_down(3*60, 'running') say "Stop running now. You have a short run and a long run left." count_down(3*60, 'walking')
say "Start your second short run." count_down(90, 'running') say "Stop running. You have 1 more long run left." count_down(90, 'walking')
say "Start your last run now." count_down(3*60, 'running') say "Stop running. After this walk, you will have finished." count_down(3*60, 'walking')
Here's my attempt: At the moment it only prints text to the screen. I have an interesting idea about generating a wav file directly, but I'm going to have trouble getting that done before the summary deadline. The script gets the exercise plan from a text file with a simple format. It doesn't do any error checking of arguments or file format.
Usage: ruby -d coach.rb weekly_plan.txt Use the -d switch unless you want to wait 20 minutes for all the output.
-----week3.txt----- run 90 walk 90 run 180 walk 180 run 90 walk 90 run 180 walk 180 ----------
-----coach.rb----- $CheerThreshold = 6 #decrease to get more random encouragement $LongThreshold = 120 #minimum time to be considered a "long" run
class Phase attr_reader :action, :seconds def initialize action, time @action = action.downcase @seconds = time.to_i end end
def coach build_timeline say summarize(2) say start_prompt @time = Time.now @target_time = @time while (phase = @phases.shift) update_summary phase narrate_phase phase if @phases.size > 0 say transition(@phases[0].action) say summarize(rand(2)) end end say finish_line end
def narrate_phase phase say what_to_do_for(phase) @target_time += phase.seconds delta = (@target_time - Time.now).to_i stepidx = 0 while (delta > 0) stepidx+=1 if delta < @step[stepidx]+1 wait_time = delta%@step[stepidx] wait_time += @step[stepidx] if wait_time <= 0 wait(wait_time) delta = (@target_time - Time.now).to_i encourage_maybe say whats_left(phase.action,delta) if delta > 0 end end
def update_summary phase @duration -= phase.seconds @runs -= 1 if phase.action == 'run' @longs -= 1 if phase.action == 'run' and phase.seconds >= $LongThreshold @walks -= 1 if phase.action == 'walk' end
def build_timeline @phases = @rawdata.map {|command| p = Phase.new(*command.split) @duration += p.seconds @runs += 1 if p.action == 'run' @longs += 1 if p.action == 'run' and p.seconds >= $LongThreshold @walks += 1 if p.action == 'walk' p } end
def say s puts s #todo: replace with speech end def wait n if $DEBUG puts "...waiting #{n} seconds..." @target_time -= n else $stdout.flush sleep(n) end end
def encourage_maybe @encouragometer += rand(3) if (@encouragometer > $CheerThreshold) say cheer @encouragometer = 0 end end
def timesay secs secs = secs.to_i s = "" if secs > 60 min = secs/60 secs -= min*60 s += "#{min} minute" s += 's' if min > 1 s += ' and ' if secs > 0 end if secs > 0 s += "#{secs} second" s += 's' if secs > 1 end s end
# All the phrases should be below this line, not mixed up in the logic def what_to_do_for phase s = "#{phase.action} for #{timesay(phase.seconds)} \n" s += "You are almost done" if @phases.size == 1 s end def whats_left act, time timestr = timesay(time) s = [ "You have #{timestr} more to #{act}", "#{act} for #{timestr} more", "only #{timestr} left of #{act}ing", "You have #{timestr} more to #{act}", "#{timestr} left in this phase", "There are #{timestr} until the next activity" ] s[rand(s.size)] end def start_prompt "are you ready, go!" end def transition next_act s = ["OK, you can #{next_act} now", "get ready to #{next_act}"] s[rand(s.size)] end def finish_line "you are done, rest now." end def cheer c = ["Keep it up!", "Way to go!", "Good Job!"] c[rand(c.size)] end def summarize degree shorts = @runs - @longs s = "you have #{timesay(@duration)}" if degree > 0 if degree > 1 s+= " for " else s+= " to go and there are " if @runs > 0 end s+="#{@longs} long run" if @longs > 0 s+="s" if @longs > 1 s+=" and" if @longs > 0 and shorts > 0 and degree <=1 s+=" #{shorts} short run" if shorts > 0 s+="s" if shorts > 1 if degree >1 s+=" and" if @longs+shorts > 0 s+=" #{@walks} walk" if @walks > 0 s+="s" if @walks > 1 else s+=" left" end else s+= " left to exercise" end s end end
> Here's my attempt: > At the moment it only prints text to the screen. I have an > interesting idea about generating a wav file directly, but I'm going > to have trouble getting that done before the summary deadline. The > script gets the exercise plan from a text file with a simple format. > It doesn't do any error checking of arguments or file format.
Dude - that's extremely cool :) It's a hell of a lot more comprehensive than my attempt. Good work :)
I really like the "encouragometer" member state, your use of randomisation, and the way you break up the phase in to steps - it seems a much less rigid solution than my own.
You have got it giving lots of propper phrases too, by just getting on with the job and using lots of branching :) I got far too hung up with trying to unify it all, and didn't really get anywhere at all! Perhaps from the point you've got to now, it would be possible to factor out some common ideas? Perhaps some kind of automatic pluralisation would be useful in "summarize"? Perhaps it's not worth it though?!
I was thinking that catagorisation of a phase in to "long" or "short" could also go in to the definition file? That would cope with weeks where there are no longer or shorter runs, and would easily allow tailoring to each week's requirements. It would be a phase description to go along with the phase's activity.
On 6/14/06, Adam Shelly <adam.she...@gmail.com> wrote:
> Here's my attempt:
Here's a quick version that is closer to having speech synth. It's not a real synthesiser, but if you can provide the corresponding ogg files it can look for certain phrases and play them. The result should sound a bit better than a real synthesiser since the sections will be spoken fairly naturally. Only 53 files to record!
Well maybe the strings you use could be made a bit more similar to shorten the list a bit ;-)
Les
------------------------
$CheerThreshold = 6 #decrease to get more random encouragement $LongThreshold = 120 #minimum time to be considered a "long" run
class SpeechSynth def initialize @known = ["minutes left in this phase", "you are done, rest now.", "until the next activity", "OK, you can walk now", "seconds more to walk", "seconds more to run", "to go and there are", "OK, you can run now", "You are almost done", "are you ready, go!", "get ready to walk", "get ready to run", "short run left", "in this phase", "to exercise", "Keep it up!", "to exersize", "Way to go!", "of walking", "short runs", "minute and", "There are", "Good job!", "long runs", "of runing", "You have", "walk for", "long run", "minutes", "seconds", "to walk", "run for", "to run", "walks", "left", "only", "more", "for", "and", "60", "30", "15", "18", "55", "7", "6", "5", "9", "3", "2", "1", "8", "4"] end
def playFile(fileName) puts "PLAY: '" +fileName + "'" #on linux this can be: #`play #{filename}` end
def known(searchPhrase) @known.each do |phrase| return $~[1] if searchPhrase =~ /^(#{phrase}).*/i end puts "UNKNOWN PHRASE: #{searchPhrase}" return nil end
def say(sentence) while sentence.length > 0 knownPhrase = known(sentence) if knownPhrase playFile(knownPhrase + ".ogg") sentence = sentence[knownPhrase.length+1..-1] sentence = "" if !sentence else sentence = "" end sentence.strip! end end end
class Phase attr_reader :action, :seconds def initialize action, time @action = action.downcase @seconds = time.to_i end end
def coach build_timeline say summarize(2) say start_prompt @time = Time.now @target_time = @time while (phase = @phases.shift) update_summary phase narrate_phase phase if @phases.size > 0 say transition(@phases[0].action) say summarize(rand(2)) end end say finish_line end
def narrate_phase phase say what_to_do_for(phase) @target_time += phase.seconds delta = (@target_time - Time.now).to_i stepidx = 0 while (delta > 0) stepidx+=1 if delta < @step[stepidx]+1 wait_time = delta%@step[stepidx] wait_time += @step[stepidx] if wait_time <= 0 wait(wait_time) delta = (@target_time - Time.now).to_i encourage_maybe say whats_left(phase.action,delta) if delta > 0 end end
def update_summary phase @duration -= phase.seconds @runs -= 1 if phase.action == 'run' @longs -= 1 if phase.action == 'run' and phase.seconds >= $LongThreshold @walks -= 1 if phase.action == 'walk' end
def build_timeline @phases = @rawdata.map {|command| p = Phase.new(*command.split) @duration += p.seconds @runs += 1 if p.action == 'run' @longs += 1 if p.action == 'run' and p.seconds >= $LongThreshold @walks += 1 if p.action == 'walk' p } end
def say s @synth.say(s) end
def wait n if $DEBUG puts "...waiting #{n} seconds..." @target_time -= n else $stdout.flush sleep(n) end end
def encourage_maybe @encouragometer += rand(3) if (@encouragometer > $CheerThreshold) say cheer @encouragometer = 0 end end
def timesay secs secs = secs.to_i s = "" if secs > 60 min = secs/60 secs -= min*60 s += "#{min} minute" s += 's' if min > 1 s += ' and ' if secs > 0 end if secs > 0 s += "#{secs} second" s += 's' if secs > 1 end s end
# All the phrases should be below this line, not mixed up in the logic def what_to_do_for phase s = "#{phase.action} for #{timesay(phase.seconds)} \n" #s += "You are almost done" if @phases.size == 1 s end def whats_left act, time timestr = timesay(time) s = [ "You have #{timestr} more to #{act}", "#{act} for #{timestr} more", "only #{timestr} left of #{act}ing", "You have #{timestr} more to #{act}", "#{timestr} left in this phase", "There are #{timestr} until the next activity" ] s[rand(s.size)] end def start_prompt "are you ready, go!" end def transition next_act s = ["OK, you can #{next_act} now", "get ready to #{next_act}"] s[rand(s.size)] end def finish_line "you are done, rest now." end def cheer c = ["Keep it up!", "Way to go!", "Good Job!"] c[rand(c.size)] end def summarize degree shorts = @runs - @longs s = "you have #{timesay(@duration)}" if degree > 0 if degree > 1 s+= " for " else s+= " to go and there are " if @runs > 0 end s+="#{@longs} long run" if @longs > 0 s+="s" if @longs > 1 s+=" and" if @longs > 0 and shorts > 0 and degree <=1 s+=" #{shorts} short run" if shorts > 0 s+="s" if shorts > 1 if degree >1 s+=" and" if @longs+shorts > 0 s+=" #{@walks} walk" if @walks > 0 s+="s" if @walks > 1 else s+=" left" end else s+= " left to exercise" end s end end
This quiz turns out to be a little bit of work, if you want to get some decent feedback to the user. Adam Shelly hammered out a reasonably complete solution though, so let's have a look at it:
$CheerThreshold = 6 #decrease to get more random encouragement $LongThreshold = 120 #minimum time to be considered a "long" run
class Phase attr_reader :action, :seconds def initialize action, time @action = action.downcase @seconds = time.to_i end end
# ...
We can see some setup work here for variables that allow users to tweak the output. We also have the trivial Phase class definition, which is just a data class for linking actions and times.
def coach build_timeline say summarize(2) say start_prompt @time = Time.now @target_time = @time while (phase = @phases.shift) update_summary phase narrate_phase phase if @phases.size > 0 say transition(@phases[0].action) say summarize(rand(2)) end end say finish_line end
# ...
There's nothing too interesting about initialize() which is just assigning defaults to the instance variables. Have a look at the coach() method though. This is the process the application runs through, and I really like how well it reads. It builds up the timeline of events, hits user with a summary and starting prompt, then launches into Phase processing. Each Phase is narrated to the user, and then the code transitions naturally to the next Phase. Finally the code sends the finish line message to indicate a successful workout.
Let's see what narrating a phase involves:
# ...
def narrate_phase phase say what_to_do_for(phase) @target_time += phase.seconds delta = (@target_time - Time.now).to_i stepidx = 0 while (delta > 0) stepidx+=1 if delta < @step[stepidx]+1 wait_time = delta % @step[stepidx] wait_time += @step[stepidx] if wait_time <= 0 wait(wait_time) delta = (@target_time - Time.now).to_i encourage_maybe say whats_left(phase.action,delta) if delta > 0 end end
# ...
Obviously, this method is mostly about time management. It breaks a Phase down into smaller chunks, so that it can provide encouragement frequently and inform the user of what is left to be done.
Note the clever output messages here again that read so naturally: what_to_do_for(), encourage_maybe(), and whats_left().
# ...
def update_summary phase @duration -= phase.seconds @runs -= 1 if phase.action == 'run' @longs -= 1 if phase.action == 'run' and phase.seconds >= $LongThreshold @walks -= 1 if phase.action == 'walk' end
def build_timeline @phases = @rawdata.map {|command| p = Phase.new(*command.split) @duration += p.seconds @runs += 1 if p.action == 'run' @longs += 1 if p.action == 'run' and p.seconds >= $LongThreshold @walks += 1 if p.action == 'walk' p } end
# ...
These two methods are quite similar save that one adds and the other subtracts. First, build_timeline() constructs the Phase objects from the import file. As it goes through, it counts things like the total number of walks and runs a person needs to complete. Then, update_summary() runs inside each Phase of the event loop ticking off the walks and runs the user has completed.
Here's the say() method that would eventually need to be replaced with speech programming:
# ...
def say s puts s #todo: replace with speech end
# ...
Now, take a look at this:
# ...
def wait n if $DEBUG puts "...waiting #{n} seconds..." @target_time -= n else $stdout.flush sleep(n) end end
# ...
This is obviously the delay method and it mainly just calls sleep(). However, I like how it can be set to just explain what the pause would have been, in $DEBUG mode. That makes testing the application much more pleasant.
Two more helper methods:
# ...
def encourage_maybe @encouragometer += rand(3) if (@encouragometer > $CheerThreshold) say cheer @encouragometer = 0 end end
def timesay secs secs = secs.to_i s = "" if secs > 60 min = secs/60 secs -= min*60 s += "#{min} minute" s += 's' if min > 1 s += ' and ' if secs > 0 end if secs > 0 s += "#{secs} second" s += 's' if secs > 1 end s end
# ...
There's the definition for the encourage_maybe() call I pointed out earlier. It just randomly decides if a cheer should be emitted.
The other method, timesay(), is a helper like we are use to in Rails. It just humanizes the output of some number of seconds by breaking it into minutes and seconds.
Next the code has several output methods, of which I'll just show a couple:
# ...
# All the phrases should be below this line, not mixed up in the logic def what_to_do_for phase s = "#{phase.action} for #{timesay(phase.seconds)} \n" s += "You are almost done" if @phases.size == 1 s end def whats_left act, time timestr = timesay(time) s = [ "You have #{timestr} more to #{act}", "#{act} for #{timestr} more", "only #{timestr} left of #{act}ing", "You have #{timestr} more to #{act}", "#{timestr} left in this phase", "There are #{timestr} until the next activity" ] s[rand(s.size)] end
# ... several more output routines not shown ... end
# ...
You can see that these methods just use simple conditional logic or random picks to vary the program's output. With several of these methods, the end result is a fairly good mix of prompts for the user.
Here's the last line that turns it into a solution:
# ...
Coach.new(ARGV[0]||"week3.txt").coach
My thanks to those who stole the time from their busy running schedules to code up a solution. These scripts should have us all in shape by RubyConf!
Tomorrow, we will try an extremely common computerism, but see if we can handle it a little better than the usual treatment...
On 6/14/06, Leslie Viljoen <leslievilj...@gmail.com> wrote:
> Here's a quick version that is closer to having speech synth. It's not > a real synthesiser, but if you can provide the corresponding ogg files > it can look for certain phrases and play them. The result should sound > a bit better than a real synthesiser since the sections will be spoken > fairly naturally. Only 53 files to record!
I know this part is after the summary (Thanks for the nice writeup) , but I wanted to share.
I had an idea similar to Leslie's, but I wanted to actually write out an audio file, instead of sending the narration to the speakers. The solution has 2 parts.
class WaveRead extracts all the information from a wave file. I put it together in under 2 hours last night. It was so much easier to write than the one in I did C a few years ago, and I'm really pleased the result. It's clean and extensible. I already have an idea for making it trivial to add the other chunk definitions.
class WaveSpeaker writes a new wave file with everything it was told to say. It does this by using a wave file feature called cues, which are a way of marking a point in the file and giving it a name. I created a wave file with several words, and a cue marking each one. (see more about this below.) WaveSpeaker parses this file, and starts writing a new output file with the same format. Then, when #say is called, it looks for each word in the list of cues, and if found, pastes the appropriate part of the source wave into the output file. It inserts silence for each #wait, compensating for the length of the previous sentences. At the end it just fixes up the filesize data, and closes the file. All you need to do is convert the file to MP3 and transfer to your iPod.
------wavespeaker.rb------ require 'Ostruct'
class RiffRead def initialize io @io = io raise "Not a RIFF file" if io.read(4) != "RIFF" @size = get_long @type = get_word end def parse chunks = [] chk = get_chunk while chk chunks << chk chk = get_chunk end chunks end def self.get_long io io.read(4).unpack('V')[0] end def self.get_short io io.read(2).unpack('v')[0] end def self.get_word io io.read(4) end
private def get_chunk tag = get_word return nil if !tag if tag == 'LIST' handle_list else size = get_long size+=1 if size%2 != 0 data = handle_tag(tag,size) data ||= @io.read(size) [tag, size, data] end end def handle_tag tag,size funcname = "parse_"+tag.strip if methods.include? funcname return self.send(funcname, size) end end def handle_list listsize = get_long @listtype = get_word ['LIST',listsize,@listtype] end def get_long self.class::get_long @io end def get_short self.class::get_short @io end def get_word self.class::get_word @io end end
To get to work with my solution, just add the following lines: in Coach#initialize, add @speaker = WaveSpeaker.new "coach.wav" @speaker.begin "current_workout.wav"
at the end of Coach#coach add @speaker.quit
and replace these two functions: def say s @speaker.say s end def wait n @speaker.wait n @target_time -= n end
To get the source file, I generated a wave file with 53 words from my coaching script using a synth (couldn't find a microphone), and used my wave editor's auto cue feature to insert numbered cues in all the gaps between words. After running simple script to replace the numbers with the words, I have a complete solution that produces a 20 minute long wav file of a robot coach. It would probably be better if you used a real voice. If anyone is actually interested in this, I can give you more details on the wave file creation.
On 6/16/06, Adam Shelly <adam.she...@gmail.com> wrote:
> On 6/14/06, Leslie Viljoen <leslievilj...@gmail.com> wrote:
> > Here's a quick version that is closer to having speech synth. It's not > > a real synthesiser, but if you can provide the corresponding ogg files > > it can look for certain phrases and play them. The result should sound > > a bit better than a real synthesiser since the sections will be spoken > > fairly naturally. Only 53 files to record!
> I know this part is after the summary (Thanks for the nice writeup) , > but I wanted to share.
> I had an idea similar to Leslie's, but I wanted to actually write out > an audio file, instead of sending the narration to the speakers. The > solution has 2 parts.
What we need now is your master wav file! This software is cool, let's use it! (look mummy, there goes a fit geek!)
There are at least 3 problems with it - It's missing a bunch of numbers, so you often get useless messages like: "you have . left to run" . Look for the "Can't Find ..." messages in the output to see what other numbers you need. - the pauses between words are too long. - The voice is really annoying.
If you are really interested, I'd record your own voice, or someone who motivates you. The process to mark it up is actually simple. Make sure there is a bit of silence between each word in your recording. Then use the AutoCue feature of your wave editor to insert cues at the end of each silence. - I used GoldWave, which made it really easy. Delete any false cues in the middle of words, and add any missing ones. - I only found one extra cue. Then run this script, making sure that the list of words matches what you recorded.
-----cuefixer.rb----- require 'wavespeaker.rb' Words = %w( run walk for second seconds minute minutes you are almost done have more to only left this phase there until the next activity ready go ok can now get rest keep it up way good job and long short runs walks exercise 1 2 3 4 5 6 10 15 30 60 in)
class CueFixer < RiffRead def initialize io super raise "Not a Wave File" if @type != 'WAVE' @out = File.open("recued_coach.wav", "wb") @out.write('RIFF') @filesize_marker = @out.pos @out.write [0].pack('V') @written = @out.write('WAVE') end def close @out.seek @filesize_marker @out.write [@written].pack('V') @out.seek @listsize_marker @out.write [@written-@liststart].pack('V') @out.close p @written end def handle_tag tag,size @written += @out.write tag chunksize_marker = @out.pos @written += @out.write [size].pack('V') funcname = "parse_"+tag.strip if methods.include? funcname size = self.send(funcname, size) here = @out.pos @out.seek chunksize_marker @out.write [size].pack('V') @out.seek here else data = @io.read(size) @written += @out.write data data end end def handle_list @written += @out.write 'LIST' @listsize_marker = @io.pos @liststart = @written+4 @written += @out.write @io.read(8) end def parse_labl size id = get_long osize = @out.write [id].pack('V') @written+=osize string = @io.read(size-4) newcue = Words[id] p newcue @written += @out.write(newcue) @written += @out.write "\0" osize += newcue.size + 1 if osize%2 != 0 @written += @out.write "\0" end osize end end
w = CueFixer.new(File.new("fullcoach.wav","rb")) w.parse w.close -----end-----