Tracking exercise progress with audio clips

For our 7 Minute Workout app, adding sound support is vital. One cannot exercise while constantly staring at the screen. Audio clues will help the user to perform the workout effectively as he/she can just follow the audio instructions.

Here is how we are going to support exercise tracking using audio clues:

  • A ticking clock sound tracks the progress during the exercise
  • A half-way indicator sounds, indicating that the exercise is halfway through
  • An exercise-completion audio clip plays when the exercise is about to end
  • An audio clip plays during the rest phase and informs users about the next exercise

Modern browsers have good support for audio. The <audio> tag of HTML5 provides a mechanism to embed audio into our HTML content. We will use it to embed and play our audio clips during different times in the app.

AngularJS does not have any inherent support to play/manage audio content. We may be tempted to think that we can just go ahead and directly access the HTML audio element in our controller and implement the desired behavior. Yes, we can do that. In fact, this would have been a perfectly acceptable solution if we had been using plain JavaScript or jQuery. However, remember there is a sacrosanct rule in Angular: "Thou shalt not manipulate DOM in the AngularJS controller" and we should never break it. So let's back off and think about what else can be done.

We touched upon the concept of directives in Chapter 1, Getting Started, and we also learned that the DOM manipulation work belongs to AngularJS directives. Since the base framework does not have a directive to support audio, the options we are left with are: writing our own directive or using a third-party directive that wraps HTML5 audio. We will take the easier route and use a third-party directive angular-media-player (https://github.com/mrgamer/angular-media-player). Another reason we do not plan to create our own directive is that the topic of directive creation is a non-trivial pursuit and will require us to get into the intricacies of how directives work. We will cover more about directives in the chapter that we have dedicated exclusively for them.

Note

The popularity of AngularJS has benefitted everyone using it. No framework can cater to the ever-evolving needs of the developer community. This void is filled by the numerous directives, services, and filters created by the community and open-sourced for everyone to use. We use one such directive angular-media-player. It is always advisable to look for such readymade components first before implementing our own.

Additionally, we should pledge to give back to the community by making public any reusable components that we create in Angular.

Let's get started with the implementation.

Implementing audio support

We have already detailed when a specific audio clip is played in the last section. If we look at the current implementation for the controller, the currentExercise and currentExerciseDuration controller properties and the startWorkout and startExercise functions are the elements of interest to us.

Note

The workout exercises created inside createWorkout at the moment do not reference the exercise name pronunciation audio clips (nameSound). Update the createWorkout function with the updated version of code from chapter3checkpoint2js7MinWorkoutworkout.js before proceeding.

GitHub branch: checkpoint3.2 (folder – trainer)

After the update, each exercise would have a property as follows:

nameSound: "content/jumpingjacks.wav"

We can use the startWorkout method to start the overall time ticker sound. Then, we can alter the startExercise method and fix the $interval call that increments currentExerciseDuration to include logic to play audio when we reach half way.

Not very elegant! We will have to alter the core workout logic just to add support for audio. There is a better way. Why don't we create a separate controller for audio and workout synchronization? The new controller will be responsible for tracking exercise progress and will play the appropriate audio clip during the exercise. Things will be clearer once we start the implementation.

To start with, download and reference the angular-media-player directive. The steps involved are:

  1. Download angular-media-player.js from https://github.com/mrgamer/angular-media-player/tree/master/dist.
  2. Create a folder vendor inside the js folder and copy the previous file.
  3. Add a reference to the preceding script file in index.html after the framework script declarations:
    <script src="js/vendor/angular-media-player.js"></script>
  4. Lastly, inject the mediaPlayer module with the existing module dependencies in app.js:
    angular.module('app', [..., '7minWorkout', 'mediaPlayer']).

Open workout.html and add this HTML fragment inside exercise div (id="exercise-pane") at the very top:

<span ng-controller="WorkoutAudioController">
  <audio media-player="ticksAudio" loop autoplay src="content/tick10s.mp3"></audio>
  <audio media-player="nextUpAudio" src="content/nextup.mp3"></audio>
  <audio media-player="nextUpExerciseAudio" playlist="exercisesAudio"></audio>
  <audio media-player="halfWayAudio" src="content/15seconds.wav"></audio>
  <audio media-player="aboutToCompleteAudio" src="content/321.wav"></audio>
</span>

In the preceding HTML, there is one audio element for each of the scenarios we need to support:

  • ticksAudio: This is used for the ticking clock sound
  • nextUpAudio: This is used for the next audio sound
  • nextUpExerciseAudio: This is the exercise name audio
  • halfWayAudio: This gets played half-way through the exercise
  • aboutToCompleteAudio: This gets played when the exercise is about to end

The media-player directive is added to each audio tag. This directive then adds a property with the same name as the one assigned to the media-player attribute on the current scope. So the media-player = "aboutToCompleteAudio" declaration adds a scope property aboutToCompleteAudio.

We use these properties to manage the audio player in WorkoutAudioController.

Other than the audio directives, there is also an ng-controller declaration for WorkoutAudioController on the span container.

With the view in place, we need to implement the controller.

Implementing WorkoutAudioController

There is something different that we have done in the previous view. We have an ng-controller declaration for WorkoutAudioController inside the existing parent controller context WorkoutController.

Note

WorkoutController gets instantiated as part of route resolution as we saw in Chapter 2, Building Our First App – 7 Minute Workout, where we defined a route, as follows:

$routeProvider.when('/workout', { templateUrl: 'partials/workout.html', controller: 'WorkoutController' });

Since WorkoutController is linked to ng-view, we have effectively nested WorkoutAudioController inside WorkoutController.

This effectively creates a child MVC component within the parent MVC component. Also, since the ng-controller directive creates a new scope, the child MVC component has its own scope to play with. The following screenshot highlights this hierarchy:

Implementing WorkoutAudioController

This new scope inherits (prototypal inheritance) from the parent scope, and has access to the model state defined on parent $scope. Such segregation of functionality helps in better organization of code and makes implementation simple. As views and controllers start to become complex, there are always opportunities to split a large view into smaller manageable subviews that can have their own model and controller as we are doing with our workout audio view and controller.

We could have moved the view template (the span container) for audio into a separate file and included it in the workout.html file using ng-include (as done for description-panel.html), hence achieving a true separation of components. However, for now we are just decorating the span with the ng-controller attribute.

Let's add the WorkoutAudioController function to the workout.js file itself. Open workout.js and start with adding the WorkoutAudioController declaration and some customary code after the WorkoutController implementation:

angular.module('7minWorkout')
  .controller('WorkoutAudioController', ['$scope', '$timeout', function ($scope, $timeout)    {
   $scope.exercisesAudio = [];
   var init = function () {
   }
   init();
}]);

The standard controller declaration has a skeleton init method and the exercisesAudio property, which will store all audio clips for each exercise defined in Exercise.nameSound.

When should this array be filled? Well, when the workout (workoutPlan) data is loaded. When does that happen? WorkoutAudioController does not know when it happens, but it can use the AngularJS watch infrastructure to find out. Since the WorkoutAudioController scope has access to the workoutPlan property defined on the parent controller scope, it can watch the property for changes. This is what we are going to do. Add this code after the declaration of the exercisesAudio array in WorkoutAudioController:

var workoutPlanwatch = $scope.$watch('workoutPlan', function (newValue, oldValue) {
    if (newValue) {  // newValue==workoutPlan
        angular.forEach( $scope.workoutPlan.exercises, 
        function (exercise) {
            $scope.exercisesAudio.push({
                src: exercise.details.nameSound,
                type: "audio/wav"
            });
        });
        workoutPlanwatch(); //unbind the watch.
    }
});

This watch loads all the exercise name audio clips into the exercisesAudio array once workoutPlan is loaded.

One interesting statement here is:

workoutPlanwatch();

As the comment suggests, it is a mechanism to remove the watch from an already watched scope property. We do it by storing the return value of the $scope.$watch function call, which is a function reference. We then can call this function whenever we want to remove the watch, which is the case after the first loading of workoutPlan data. Remember the workout data is not going to change during the workout.

Similarly, to track the progress of the exercise, we need to watch for the currentExercise and currentExerciseDuration properties. Add these two watches following the previous watch:

$scope.$watch('currentExercise', function (newValue, oldValue) {
  if (newValue && newValue !== oldValue) {
    if ($scope.currentExercise.details.name == 'rest') {
      $timeout(function () { $scope.nextUpAudio.play();}
, 2000);
      $timeout(function () { $scope.nextUpExerciseAudio.play($scope.currentExerciseIndex + 1, true);}
, 3000);
}
    }
});

$scope.$watch('currentExerciseDuration', function (newValue, oldValue) {
if (newValue) {
if (newValue == Math.floor($scope.currentExercise.duration / 2) && $scope.currentExercise.details.name !== 'rest') {
         $scope.halfWayAudio.play();
        } 
   else if (newValue == $scope.currentExercise.duration - 3) {
            $scope.aboutToCompleteAudio.play();
        }
    }
});

The first watch on currentExercise is used to play the audio of the next exercise in line during the rest periods. Since the audio for the next exercise is a combination of two audio clips, one that echoes next-up and another that echoes the exercise name (from the array that we have built previously using the workoutPlan watch), we play them one after another. This is how the audio declaration for the the next-up audio looks:

<audio media-player="nextUpAudio" src="content/nextup.mp3"></audio>
<audio media-player="nextUpExerciseAudio" playlist="exercisesAudio"></audio>

The first one is like other audio elements that take the audio using the src attribute. However, the nextUpExerciseAudio value takes playlist, which is an array of audio sources. During every rest period, we play the audio from one of the array elements by calling:

$scope.nextUpExerciseAudio.play($scope.currentExerciseIndex + 1, true);}

To play the audio content in succession, we use $timeout to control the audio playback order. One plays after 2 seconds and the next after 3 seconds.

The second watch on currentExerciseDuration gets invoked every second and plays specific audio elements at mid-time and before the exercise ends.

Note

The media-player directive exposes a number of functions/properties on the object that it adds to the current scope. We have only used the play method. The media-player documentation has more details on other supported functions/properties.

Now is the time to verify the implementation, but before we do that we need to include the audio clips that we have referenced in the code. The audio files are located in the audio folder of the app inside chapter3/checkpoint2/app/content.

Copy the audio clips and refresh the workout page. Now we have full-fledged audio support in 7 Minute Workout. Wait a minute, there is a small issue! The next up exercise audio is off by one. To verify this, wait for the first exercise to complete and the rest period to start. The next up exercise audio does not match with the exercise that is coming up next, it is one step ahead. Let's fix it.

Exploring the audio synchronization issue

To fix the audio synchronization issue, let's first debug and identify what is causing the problem.

Put a breakpoint inside workoutPlan watch (inside the if condition) and start the workout. Wait for the breakpoint to hit. When it hits, check the value of workoutPlan.exercises:

Exploring the audio synchronization issue

The first time this watch is triggered (newValue is not null), the workoutPlan.exercises array has 11 elements as seen in the previous screenshot. Nonetheless, we added 12 exercises when we loaded the plan for the first time in startWorkout. This is causing the synchronization issue between the next-up audio and the exercise order. However, why do we have one element fewer?

The first line inside the startWorkout function does the necessary assignment to workoutPlan:

$scope.workoutPlan = createWorkout();

If our understanding of watches is correct, then the watch should get triggered as soon as we assign $scope.workoutPlan a value—in other words, whenever $scope.workoutPlan changes.

This is not the case, and it can be confirmed by debugging the startWorkout function. The watch does not trigger while we are inside startWorkout and well beyond that. The last line of startWorkout is:

startExercise($scope.workoutPlan.exercises.shift());

By removing an item from the exercises array, we are one item short when the watch actually triggers.

As it turns out, change detection/tracking does not work in real-time. Clearly, our understanding of watches is not 100 percent correct!

To fix this innocuous looking problem, we will have to dig deeper into the inner working of Angular and then fix parts of our workout implementation.

Note

The next section (AngularJS dirty checking and digest cycles) explores the internal workings of the Angular framework that can become a bit overwhelming if we have just started learning this framework. Feel free to skip this section and revisit it in the future. We will summarize our understanding of Angular dirty checking and digest cycle execution at the end of the section, before we actually fix the audio synchronization issue.

AngularJS dirty checking and digest cycles

Let's step back and try to understand how the AngularJS watch infrastructure works. How is it able to update HTML DOM on model data changes? Remember HTML directives and interpolations too use the same watch infrastructure.

The properties that we watch in Angular are standard JavaScript objects/values and since JavaScript properties (at least till now) are not observable, there is no way for Angular to know when the model data changed.

This raises the fundamental question: how does AngularJS detect these changes?

Well, AngularJS detects changes only when the $scope.$apply(exp) function is invoked. This function can take an argument exp that it evaluates in the current scope context. Internally, $apply evaluates exp and then calls the $rootScope.$digest() function.

The call to $digest() triggers the model change detection process. The immediate question that comes to mind is: "when is $apply called and who calls it?" Before we can answer this question, it would be good to know what happens in the digest cycle.

The invoking of the $digest() function on $rootScope in the Angular world is called the digest cycle. It is termed as cycle because it is a repeating process. What happens during the digest loop is that Angular internally starts two smaller loops as follows:

  • The $evalAsync loop: $evalAsync is a method on the $scope object that allows us to evaluate an expression in an asynchronous manner before the next digest loop runs. Whenever we register some work with $evalAsync, it goes into a list. During the $evalAsync loop, items in this list are evaluated till the list is empty and this ends the loop. We seldom need it; in fact I have never used it.
  • The $watch list loop. All the watches that we register, or are registered by the framework directives and interpolations, are evaluated in this loop.

    To detect the model changes, Angular does something called as dirty checking. This involves comparing the old value of the model property with the current value to detect any changes. For this comparison to work, Angular needs to do some book keeping that involves keeping track of the model value during the last digest cycle.

    If the framework detects any model changes from the last digest cycle, the corresponding model watch is triggered. Interestingly, this watch triggering can lead to a change in model data again, hence triggering another watch.

    For example, if the $watch callback updates some model data on the scope that is being watched by another watch expression, another watch will get triggered.

    Angular keeps reevaluating the watch expression until no watch gets triggered or, in other words, the model becomes stable. At this moment, the watch list loop ends.

Note

To safeguard against an infinite loop, the watch list loop runs only 10 times after which an error is thrown and this loop is terminated. We will see an error like this in the developer console:

Uncaught Error: 10 $digest() iterations reached. Aborting!

We should be careful when updating the scope data in a watch. The update should not result in an infinite cycle. For example, if we update the same property that is being watched inside the watch callback, we will get an error.

When both the $evalAsync and $watch loops are complete, AngularJS updates the HTML DOM and the digest cycle itself ends.

An important thing that needs to be highlighted here is that the digest cycle evaluates every model property being watched in any scope across the application. This may seem inefficient at first as on each digest cycle we evaluate each and every property being watched irrespective of where the changes are made, but this works very well in real life.

Note

See the answer at http://stackoverflow.com/questions/9682092/databinding-in-angularjs/9693933#9693933 by Misko (creator of Angular!) to know why it is not such a bad idea to implement dirty checking in this manner.

The only missing piece of the model change tracking jigsaw is: when is $scope.$apply called or when does the digest cycle run? Till now we have never invoked the $scope.$apply method anywhere.

Angular made a very plausible assumption about when the model can change. It assumes model data can get updated on events such as user interaction (via the mouse and keyboard), form field updates, Ajax calls, or timer functions such as setTimeout and setInteval. It then provided a set of directives and services that wrap these events and internally call $scope.$apply when such events occur.

Here is a source code snippet (simplified) from the ng-click directive in AngularJS:

element.on("click", function(event) {
  scope.$apply(function() {
    fn(scope, {$event:event});
   });
 });

The element.on method is a jQuery- (or jqlite)-based method that adds an event handler for click events. When the mouse click event occurs, the event handler calls scope.$apply, hence triggering the digest cycle. It is precisely for this reason that we do not litter our implementation with calls to $scope.$apply() everywhere.

Summarizing our learnings

To recap:

  • An Angular watch does not trigger as soon as a model being watched changes.
  • A watch is only triggered during a digest cycle. A digest cycle is in an iterative process during which Angular compares the old and new values of the watched expression for changes. If the value changes, the watch is triggered. Angular does this for all watches that we create and the ones created by the framework to support data binding.
  • A digest cycle is triggered by calling $scope.$apply. The framework calls $scope.$apply at various times during the app execution. For example, when a button is clicked, or when $interval lapses.

The concept of the digest cycle is very important to understand once we get into serious AngularJS development. This can save us countless hours of debugging and frustration that accompanies it. In the next section, we will make use of this newfound understanding of the digest cycle to fix the audio synchronization issue.

Fixing the next-up exercise's audio synchronization issue

Now that we know what the digest cycle is, and that change detection is not real-time, things start to make sense. The workoutPlan watch did not trigger between these two calls in startWorkout:

var startWorkout = function () {
   $scope.workoutPlan = createWorkout();
   // Existing code
   startExercise($scope.workoutPlan.exercises.shift());
 }

Here, the exercise and exercise audio arrays went out of sync. Let's fix it.

Removing elements from the exercise array after each exercise is a suboptimal implementation as we have to build this array every time the workout starts. It will be better if we do not alter the array once the exercise starts and instead use the currentExerciseIndex property with the exercises array to always locate the current exercise in progress.

Go ahead and copy the updated WorkoutController functions: startWorkout, startExercise, and getNextExercise from workout.js located in chapter2/checkpoint2/app/js/7MinWorkout.

The updated functions now are using the currentExerciseIndex instead of removing items from the exercises array.

There is a fix required in workout.html too. Update the highlighted code:

<h3 class="col-sm-6 text-right" ng-if="currentExercise.details.name=='rest'">Next up: <strong>{{workoutPlan.exercises[currentExerciseIndex + 1].details.title}}</strong></h3>

Now, the upcoming audio should be in sync with the next exercise. We can verify it by running the workout again and listening to the upcoming exercise audio during the rest period.

Note

Chapter3Checkpoint2 has the working version of the code that we have implemented so far.

GitHub branch: checkpoint3.2 (folder – trainer)

Other than learning about dirty checking and digest cycles, we have learned other important things in this section too. Let's summarize them as follows:

  • We have extended the workout functionality without altering the main WorkoutController function in any way.
  • Nested controllers allow us to manage subviews independently of each other and such views are only dependent on their parent view for scope data.

    This means that changes to scope properties/schemas on the parent can affect these child views. For example, our WorkoutAudioController function is dependent on properties with these names: currentExercise and currentExerciseDuration and if we decide to rename/remove any of them, we need to fix the audio view and the controller.

    This also implies that we cannot move the workout audio-related view outside the parent view due to the dependency on model data. If we want something truly reusable, we will have to look at creating our own directive.

  • DOM manipulation should be restricted to directives and we should never do DOM manipulation in a controller.

With audio support out of the way, we are one step closer to a fully functional app. One of the missing features that will be an essential addition to our app is the exercise pause feature.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.217.5.86