
The task is to implement a   metronome.

The metronome should be capable of producing high and low audio beats, accompanied by a visual beat indicator, and the beat pattern and tempo should be configurable.

For the purpose of this task, it is acceptable to play sound files for production of the beat notes, and an external player may be used.

However, the playing of the sounds should not interfere with the timing of the metronome.

The visual indicator can simply be a blinking red or green area of the screen (depending on whether a high or low beat is being produced), and the metronome can be implemented using a terminal display, or optionally, a graphical display, depending on the language capabilities.

If the language has no facility to output sound, then it is permissible for this to implemented using just the visual indicator.




Structure METRONOMEs
  msPerBeat.i
  BeatsPerMinute.i
  BeatsPerCycle.i
  volume.i
  canvasGadget.i
  w.i
  h.i
  originX.i
  originY.i
  radius.i
  activityStatus.i 
EndStructure
 
Enumeration       ;gadgets
  #TEXT_MSPB      ;milliseconds per beat
  #STRING_MSPB    ;milliseconds per beat
  #TEXT_BPM       ;beats per minute
  #STRING_BPM     ;beats per minute
  #TEXT_BPC       ;beats per cycle
  #STRING_BPC     ;beats per cycle
  #BUTTON_VOLM    ;volume -
  #BUTTON_VOLP    ;volume +
  #BUTTON_START   ;start
  #SPIN_BPM       
  #CANVAS_METRONOME
EndEnumeration  
 
Enumeration       ;sounds
  #SOUND_LOW
  #SOUND_HIGH
EndEnumeration
 
#WINDOW = 0       ;window
 
Procedure handleError(Value, text.s)
  If Not Value: MessageRequester("Error", text): End: EndIf
EndProcedure
 
Procedure drawMetronome(*m.METRONOMEs, Angle.f, cycleCount = 0)
  Protected CircleX, CircleY, circleColor
  If StartDrawing(CanvasOutput(*m\canvasGadget))
      Box(0, 0, *m\w, *m\h, RGB(0, 0, 0))
      CircleX = Int(*m\radius * Cos(Radian(Angle)))
      CircleY = Int(*m\radius * Sin(Radian(Angle)))
      If Angle = 90
        If cycleCount: circleColor = RGB(255, 0, 0): Else: circleColor = RGB(0, 255, 0): EndIf
        LineXY(*m\originX, *m\originY, *m\originX, *m\originY - CircleY, RGB(255, 255, 0)) 
        Circle(*m\originX + CircleX, *m\originY - CircleY - *m\radius * 0.15, 10, circleColor)
      Else
        LineXY(*m\originX, *m\originY - *m\radius * 1.02, *m\originX, *m\originY - *m\radius, RGB(255, 255, 0)) 
        LineXY(*m\originX, *m\originY, *m\originX + CircleX, *m\originY - CircleY, RGB(255, 255, 0))
      EndIf
 
    StopDrawing()
 
    ProcedureReturn 1
  EndIf
EndProcedure
 
Procedure.i Metronome(*m.METRONOMEs)
  Protected milliseconds = Int((60 * 1000) / *m\BeatsPerMinute)
  Protected msPerFrame, framesPerBeat
  Protected i, j, cycleCount, startTime, frameEndTime, delayTime, delayError, h.f
 
  ;calculate metronome angles for each frame of animation
  If *m\BeatsPerMinute < 60
    framesPerBeat = Round(milliseconds / 150, #PB_Round_Nearest)
  Else
    framesPerBeat = Round((*m\BeatsPerMinute - 420) / -60, #PB_Round_Nearest)
  EndIf
 
  If framesPerBeat < 1
    framesPerBeat = 1
    Dim metronomeFrameAngle.f(1, framesPerBeat)
    metronomeFrameAngle(0, 1) = 90
    metronomeFrameAngle(1, 1) = 90
  Else  
    Dim metronomeFrameAngle.f(1, framesPerBeat * 2)
    For j = 1 To framesPerBeat
      h = 45 / framesPerBeat
      metronomeFrameAngle(0, j) = 90 - h * (j - 1)
      metronomeFrameAngle(0, framesPerBeat + j) = 45 + h * (j - 1)
      metronomeFrameAngle(1, j) = 90 + h * (j - 1)
      metronomeFrameAngle(1, framesPerBeat + j) = 135 - h * (j - 1)
    Next
    framesPerBeat * 2
  EndIf
  msPerFrame   = milliseconds / framesPerBeat
 
  PlaySound(#SOUND_HIGH)
  startTime = ElapsedMilliseconds()
  Repeat 
    For i = 0 To 1
      frameEndTime = startTime + msPerFrame
      For j = 1 To framesPerBeat
        drawMetronome(*m, metronomeFrameAngle(i, j), cycleCount)
 
        ;check for thread exit
        If *m\activityStatus < 0
          *m\activityStatus = 0
          ProcedureReturn
        EndIf
 
        delayTime = frameEndTime - ElapsedMilliseconds()
        If (delayTime - delayError) >= 0
          Delay(frameEndTime - ElapsedMilliseconds() - delayError) ;wait the remainder of frame
        ElseIf delayTime < 0
          delayError = - delayTime
        EndIf 
        frameEndTime + msPerFrame
      Next
 
      ;check for thread exit
      If *m\activityStatus < 0
        *m\activityStatus = 0
        ProcedureReturn
      EndIf
 
      While (ElapsedMilliseconds() - startTime) < milliseconds:  Wend
 
      SetGadgetText(*m\msPerBeat, Str(ElapsedMilliseconds() - startTime))
      cycleCount + 1: cycleCount % *m\BeatsPerCycle
      If cycleCount = 0
        PlaySound(#SOUND_HIGH)
      Else
        PlaySound(#SOUND_LOW)
      EndIf 
      startTime + milliseconds
    Next 
  ForEver
EndProcedure
 
Procedure startMetronome(*m.METRONOMEs, MetronomeThread) ;start up the thread with new values
  *m\BeatsPerMinute = Val(GetGadgetText(#STRING_BPM))
  *m\BeatsPerCycle  = Val(GetGadgetText(#STRING_BPC))
  *m\activityStatus = 1
 
  If *m\BeatsPerMinute
    MetronomeThread = CreateThread(@Metronome(), *m)
  EndIf
  ProcedureReturn MetronomeThread
EndProcedure
 
Procedure stopMetronome(*m.METRONOMEs, MetronomeThread) ;if the thread is running: stop it
  If IsThread(MetronomeThread)
    *m\activityStatus = -1 ;signal thread to stop
  EndIf
  drawMetronome(*m, 90)
EndProcedure
 
 
Define w = 360, h = 360, ourMetronome.METRONOMEs
 
;initialize the metronome
With ourMetronome
  \msPerBeat     = #STRING_MSPB
  \canvasGadget   = #CANVAS_METRONOME
  \volume        = 10
  \w             = w
  \h             = h
  \originX       = w / 2
  \originY       = h / 2
  \radius        = 100
EndWith
 
ourMetronome\canvasGadget = #CANVAS_METRONOME
 
;initialize sounds
handleError(InitSound(), "Sound system is Not available")
handleError(CatchSound(#SOUND_LOW, ?sClick, ?eClick - ?sClick), "Could Not CatchSound")
handleError(CatchSound(#SOUND_HIGH, ?sClick, ?eClick - ?sClick), "Could Not CatchSound")
SetSoundFrequency(#SOUND_HIGH, 50000)
SoundVolume(#SOUND_LOW, ourMetronome\volume)
SoundVolume(#SOUND_HIGH, ourMetronome\volume) 
 
;setup window & GUI
Define Style, i, wp, gh
 
Style = #PB_Window_SystemMenu | #PB_Window_ScreenCentered | #PB_Window_MinimizeGadget
handleError(OpenWindow(#WINDOW, 0, 0, w + 200 + 12, h + 4, "Metronome", Style), "Not OpenWindow")
SetWindowColor(#WINDOW, $505050) 
 
If LoadFont(0, "tahoma", 9, #PB_Font_HighQuality | #PB_Font_Bold)
  SetGadgetFont(#PB_Default, FontID(0))
EndIf 
 
i = 3: wp = 10: gh = 22
TextGadget(#TEXT_MSPB, w + wp, gh * i, 100, gh, "MilliSecs/Beat ", #PB_Text_Center)
StringGadget(#STRING_MSPB, w + wp + 108, gh * i, 90, gh, "0", #PB_String_ReadOnly): i + 2
TextGadget(#TEXT_BPM, w + wp, gh * i, 100, gh,"Beats/Min ", #PB_Text_Center)
StringGadget(#STRING_BPM, w + wp + 108, gh * i, 90, gh, "120", #PB_String_Numeric): i + 2
GadgetToolTip(#STRING_BPM, "Valid range is 20 -> 240")
TextGadget(#TEXT_BPC, w + wp, gh * i, 100, gh,"Beats/Cycle ", #PB_Text_Center)
StringGadget(#STRING_BPC, w + wp + 108, gh * i, 90, gh, "4", #PB_String_Numeric): i + 2
GadgetToolTip(#STRING_BPC, "Valid range is 1 -> BPM")
ButtonGadget(#BUTTON_START, w + wp, gh * i, 200, gh, "Start", #PB_Button_Toggle): i + 2
ButtonGadget(#BUTTON_VOLM, w + wp, gh * i, 100, gh, "-Volume")
ButtonGadget(#BUTTON_VOLP, w + wp + 100, gh * i, 100, gh, "+Volume") 
CanvasGadget(ourMetronome\canvasGadget, 0, 0, ourMetronome\w, ourMetronome\h, #PB_Image_Border)
drawMetronome(ourMetronome, 90) 
 
Define msg, GID, MetronomeThread, Value
Repeat ;the control loop for our application
  msg = WaitWindowEvent(1)
  GID = EventGadget()
  etp = EventType()
 
  If GetAsyncKeyState_(#VK_ESCAPE): End: EndIf ;remove when app is o.k.
 
  Select msg
 
    Case #PB_Event_CloseWindow
      End
 
    Case #PB_Event_Gadget
      Select GID
 
        Case #STRING_BPM
          If etp = #PB_EventType_LostFocus
            Value = Val(GetGadgetText(#STRING_BPM))
            If Value > 390
            Value = 390
            ElseIf Value < 20
            Value = 20
            EndIf 
            SetGadgetText(#STRING_BPM, Str(Value))
          EndIf 
 
        Case #STRING_BPC
          If etp = #PB_EventType_LostFocus
            Value = Val(GetGadgetText(#STRING_BPC))
            If Value > Val(GetGadgetText(#STRING_BPM))
              Value = Val(GetGadgetText(#STRING_BPM))
            ElseIf Value < 1
              Value = 1
            EndIf 
            SetGadgetText(#STRING_BPC, Str(Value))
          EndIf 
 
        Case #BUTTON_VOLP, #BUTTON_VOLM ;change volume 
          If GID = #BUTTON_VOLP And ourMetronome\volume < 100
            ourMetronome\volume + 10
          ElseIf GID = #BUTTON_VOLM And ourMetronome\volume > 0
            ourMetronome\volume - 10
          EndIf
          SoundVolume(#SOUND_LOW, ourMetronome\volume)
          SoundVolume(#SOUND_HIGH, ourMetronome\volume)
 
        Case #BUTTON_START ;the toggle button for start/stop
          Select GetGadgetState(#BUTTON_START)
            Case 1
              stopMetronome(ourMetronome, MetronomeThread)
              MetronomeThread = startMetronome(ourMetronome, MetronomeThread)
              SetGadgetText(#BUTTON_START,"Stop")
            Case 0
              stopMetronome(ourMetronome, MetronomeThread)
              SetGadgetText(#BUTTON_START,"Start")
          EndSelect
 
      EndSelect
  EndSelect
ForEver
End
 
DataSection
  ;a small wav file saved as raw data
  sClick:
  Data.q $0000082E46464952,$20746D6645564157,$0001000100000010,$0000AC440000AC44
  Data.q $6174616400080001,$8484848300000602,$8B8A898886868585,$C0B3A9A29C95918E
  Data.q $31479BD3CED0CFCF,$3233323232323331,$BDAEC4C9A1423133,$D0CFCFD0CFD1CDD4
  Data.q $323232333770A9CB,$2E34313332333232,$CFD0CFCACFCFAF53,$9783AAD3CED0CFD0
  Data.q $3233313332448AA1,$4430333233323233,$CFD0CFAE7D7E9483,$B7B9C3B8BFD1CED0
  Data.q $3233313037476898,$3E48493D32333233,$85959187775C4338,$898A8F8D807A7978
  Data.q $737F898381888D8D,$3131435564717A77,$332F36413A343234,$827B7873695C4D37
  Data.q $9C9B949191908F8A,$4D50515A67758694,$5451484341414245,$7B83868782756559
  Data.q $565D5F616365676E,$7871675F57504B4E,$797C8083827E7C7B,$4D525E686C6D7176
  Data.q $4D4B4B474343464A,$8B82796F655D5953,$7B7C83888B909392,$5153595E666F767B
  Data.q $5A5A5B5B59575553,$696C6E6D67615E5B,$7879777573726F6B,$71727376797C7B79
  Data.q $505352505159646C,$6B6153463C3B414A,$A09B908379706D6E,$6F767A7E858C949C
  Data.q $4D4D4845484E5662,$80796F645B544E4C,$8487888885828283,$555A5D606369737D
  Data.q $6A665F58524E4E51,$878E8F8B867D736D,$54606B72797F8081,$5852504F4E4E4C4D
  Data.q $7E7B7A79756F675F,$6B6C6F757C7F8182,$6D6865676C6F6E6C,$6E6B6C7074777773
  Data.q $615E5D60676F7372,$7069636061636463,$81817F7C7A797976,$65676B6E72797E80
  Data.q $65625F5E5D5D5F62,$7D7C7B7875716E6A,$6F74777B7E7F7F7E,$454950565D63676A
  Data.q $605A55504B464343,$9E9F9D978E817469,$6E7D8A93989A9B9C,$444546494C505861
  Data.q $7B756F665C534C46,$7E82858788888580,$5C5D61666B70757A,$6A6B6B6B6965615E
  Data.q $646B717676736F6B,$727272716D676261,$8285878885817A74,$5F5F636A72797D80
  Data.q $645D58585A5E6060,$827D79777877746D,$7878797C80848685,$6A686664666B7075
  Data.q $76726C666364686B,$807E7B7878787878,$656A6F74797B7D7F,$59585A5C5D5F6163
  Data.q $84807C79746D665E,$777C81878B8C8B89,$555352555B636B72,$82776B625C595957
  Data.q $989B9A979493918B,$656A6E7277818A92,$535457575656585E,$6E6E6F6D675E5753
  Data.q $898C9398968D7F74,$69717E8C9697918B,$3B39414F5D656767,$695B56565A595145
  Data.q $8986878A8F90897B,$7A7875757B848A8C,$747168605E636D76,$7B7365585257636F
  Data.q $8C8981777272777C,$70757676767A8188,$6A6D6D68625F6269,$8687847D746C6868
  Data.q $8485888A89868484,$585A616B747B8083,$5B555355595C5C5A,$8C898786847D7265
  Data.q $888C9096999A9892,$6163666C72797F83,$5E554E4B4C52595E,$91887C7169676663
  Data.q $8E8E8A88888C9193,$656666676A737E88,$655F585352555B62,$8B88837C756F6B69
  Data.q $7E858B8E8E8B898B,$62676B6D6D6C6E74,$6C6C6B6A6764605F,$7C7978787775736F
  Data.q $8E8C8C8C8D8D8982,$686D747C858C9091,$625C58585B5E6265,$908A837C75706C68
  Data.q $848A8E9396989896,$545960676E757A7F,$65605E5D5B575352,$A19D9890877C746C
  Data.q $8992979A9C9FA1A2,$525355575C64707E,$736D665D56514F50,$8D8D8B8986827E79
  Data.q $7777797B7F84898C,$78746F6A6A6E7376,$7B79767372747879,$6C6B69686A6F757A
  Data.q $7C78746F6D6C6C6D,$888B8C8B88858280,$6F75797B7D7F8184,$6F6D6B686565676A
  Data.q $8785817D79747270,$868B8D8D8C8A8988,$6F73777A7C7D7F82,$6F6B68656465666A
  Data.q $8A837D7976757472,$76787A7D82888C8D,$6562606064696F73,$8E8A847C756F6B68
  Data.q $8D90939494929190,$606264686F788088,$73685D58585A5D5F,$9B96918C8987837D
  Data.q $71767B838C959B9D,$5B5756585D63686D,$8C86817B756F6962,$888F939597979591
  Data.q $5C606366696F7780,$7A756D6259535458,$9EA2A0988D837E7C,$79858D8F8F8E9097
  Data.q $656B6E6D6865666E,$797A79746C656060,$7F7F7F7F7E7C7978,$8381828384848280
  Data.q $7F82888E92918D88,$59606A757C7F7F7F,$655E5A5A59585656,$A59F97918A82796E
  Data.q $7E848A939CA6ABAA,$48454548515E6B76,$8A7C6E6057514E4B,$A9ACABA8A4A09B94
  Data.q $5A626A737F8C98A3,$60554D49484A4E54,$A9A19A928A81776B,$848C969FA7ADAFAD
  Data.q $4B494C525C67717B,$8A7D7167605A544F,$A2A4A5A6A6A49F96,$626970777F89949C
  Data.q $6B655E56504F545A,$A19F988F8379736E,$848C9296989A9C9F,$61626465676B717A
  Data.q $807E79736D676261,$86898B8C8A888583,$797A7E8284848484,$77777A7C7E7E7C7A
  Data.q $7979797B7D7E7C79,$7D7C7B7B7C7C7B7A,$7D7D7C7B7A7B7C7D,$797978777677797B
  Data.q $787A7A7976757678,$415380817C777576,$2C31000002005255,$30202C36202C3020
  eClick:
EndDataSection