Create a drum kit using RxJS and Angular standalone components

Connie Leung - Feb 18 '23 - - Dev Community

Introduction

This is day 1 of Wes Bos's JavaScript 30 challenge where I create a drum kit to play sounds when keys are pressed. In the tutorial, I created the components using RxJS, Angular standalone components and removed the NgModules.

In this blog post, I describe how the drum component (parent) uses RxJS fromEvent to listen to keydown event, discard unwanted keys and play sound when "A", "S", "D", "F", "G", "H", "J", "K" or "L" is hit. When the correct key is pressed, the parent updates the subject that drum kit components (children) subscribe to. Then, the child with the matching key plays the corresponding sound to make a tune.

Create a new Angular project

ng generate application day1-javascript-drum-kit
Enter fullscreen mode Exit fullscreen mode

Bootstrap AppComponent

First, I convert AppComponent into standalone component such that I can bootstrap AppComponent and inject providers in main.ts.

// app.component.ts

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { DrumComponent } from './drum';

@Component({
  selector: 'app-root',
  imports: [
    DrumComponent,
  ],
  template: '<app-drum></app-drum>',
  styles: [`
    :host {
      display: block;
      height: 100vh;
    }
  `],
  standalone: true,
})
export class AppComponent {
  title = 'RxJS Drum Kit';

  constructor(private titleService: Title) {
    this.titleService.setTitle(this.title);
  }
}
Enter fullscreen mode Exit fullscreen mode

In Component decorator, I put standalone: true to convert AppComponent into a standalone component.

Instead of importing DrumComponent in AppModule, I import DrumComponent (that is also a standalone component) in the imports array because the inline template references it.

// main.ts

import { enableProdMode, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { AppComponent } from './app/app.component';
import { browserWindowProvider, windowProvider } from './app/core/services';
import { environment } from './environments/environment';

bootstrapApplication(AppComponent, {
  providers: [
    {
      provide: APP_BASE_HREF,
      useFactory: () => inject(PlatformLocation).getBaseHrefFromDOM(),
    },
    browserWindowProvider,
    windowProvider,
  ]
})
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

browserWindowProvider and windowProvider are providers from core folder and I will show the source codes later.

Second, I delete AppModule because it is not used anymore.

Add window service to listen to keydown event

In order to detect key down on native Window, I write a window service to inject to ScrollComponent to listen to keydown event. The sample code is from Brian Love's blog post here.

// core/services/window.service.ts

import { isPlatformBrowser } from "@angular/common";
import { ClassProvider, FactoryProvider, InjectionToken, PLATFORM_ID } from '@angular/core';

/* Create a new injection token for injecting the window into a component. */
export const WINDOW = new InjectionToken('WindowToken');

/* Define abstract class for obtaining reference to the global window object. */
export abstract class WindowRef {
  get nativeWindow(): Window | Object {
    throw new Error('Not implemented.');
  }
}

/* Define class that implements the abstract class and returns the native window object. */
export class BrowserWindowRef extends WindowRef {
  constructor() {
    super();
  }

  override get nativeWindow(): Object | Window {
    return window;    
  }
}

/* Create an factory function that returns the native window object. */
function windowFactory(browserWindowRef: BrowserWindowRef, platformId: Object): Window | Object {
  if (isPlatformBrowser(platformId)) {
    return browserWindowRef.nativeWindow;
  }
  return new Object();
}

/* Create a injectable provider for the WindowRef token that uses the BrowserWindowRef class. */
export const browserWindowProvider: ClassProvider = {
  provide: WindowRef,
  useClass: BrowserWindowRef
};

/* Create an injectable provider that uses the windowFactory function for returning the native window object. */
export const windowProvider: FactoryProvider = {
  provide: WINDOW,
  useFactory: windowFactory,
  deps: [ WindowRef, PLATFORM_ID ]
};
Enter fullscreen mode Exit fullscreen mode

I export browserWindowProvider and windowProvider to inject both providers in main.ts.

Declare Drum and DrumKey components

I declare standalone components, DrumComponent and DrumKeyComponent, to create a drum kit. To verify they are standalone, standalone: true is specified in the Component decorator.

src/app
├── app.component.ts
├── core
│   └── services
│       ├── index.ts
│       └── window.service.ts
├── drum
│   ├── drum.component.ts
│   └── index.ts
├── drum-key
│   ├── drum-key.component.ts
│   └── index.ts
├── helpers
│   ├── get-asset-path.ts
│   ├── get-host-native-element.ts
│   └── index.ts
├── interfaces
│   ├── index.ts
│   └── key.interface.ts
└── services
    ├── drum.service.ts
    └── index.ts
Enter fullscreen mode Exit fullscreen mode
// get-asset-path.ts

import { APP_BASE_HREF } from '@angular/common';
import { inject } from '@angular/core';

export const getFullAssetPath = () => {
    const baseHref = inject(APP_BASE_HREF);
    const isEndWithSlash = baseHref.endsWith('/');
    return `${baseHref}${isEndWithSlash ? '' : '/'}assets/`;
}
Enter fullscreen mode Exit fullscreen mode
// get-host-native-element.ts

import { ElementRef, inject } from '@angular/core';

export const getHostNativeElement = 
() => inject<ElementRef<HTMLElement>>(ElementRef<HTMLElement>).nativeElement;

getFullAssetPath and getHostNativeElement are helper functions that inject application base href and host native element in the construction phase of the components.
Enter fullscreen mode Exit fullscreen mode
// drum.component.ts

import { NgFor } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject } from '@angular/core';
import { filter, fromEvent, map } from 'rxjs';
import { WINDOW } from '../core/services';
import { DrumKeyComponent } from '../drum-key/drum-key.component';
import { getFullAssetPath, getHostNativeElement } from '../helpers';
import { DrumService } from '../services';

const getImageUrl = () => { 
  const imageUrl = `${getFullAssetPath()}images/background.jpg`;
  return `url('${imageUrl}')`;
}

const getEntryStore = () => { 
  const getEntryStore = inject(DrumService); 
  return getEntryStore.getEntryStore();
};

const windowKeydownSubscription = () => {
  const drumService = inject(DrumService);
  const allowedKeys = getEntryStore().allowedKeys;
  return fromEvent(inject<Window>(WINDOW), 'keydown')
    .pipe(
      filter(evt => evt instanceof KeyboardEvent),
      map(evt => evt as KeyboardEvent),
      map(({ key }) => key.toUpperCase()),
      filter(key => allowedKeys.includes(key)),
    ).subscribe((key) => drumService.playSound(key));
}

@Component({
  imports: [
    NgFor,
    DrumKeyComponent,
  ],
  standalone: true,
  selector: 'app-drum',
  template: `
    <div class="keys">
      <app-drum-key *ngFor="let entry of entries" [entry]="entry" class="key"></app-drum-key>
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DrumComponent implements OnInit, OnDestroy {
  entries = getEntryStore().entries;
  hostElement = getHostNativeElement();
  imageUrl = getImageUrl();
  subscription = windowKeydownSubscription();

  ngOnInit(): void {
    this.hostElement.style.backgroundImage = this.imageUrl;
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

DrumComponent imports DrumKeyComponent and NgFor to render different drum keys. NgFor is required because inline template uses ng-for directive to create a drum kit. windowKeydownSubscription uses RxJS to create an Observable to observe keydown event and subscribe the Observable to return an instance of Subscription.

// drum-key.component.ts

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, inject } from '@angular/core';
import { filter, fromEvent, map } from 'rxjs';
import { getFullAssetPath, getHostNativeElement } from '../helpers';
import { Key } from '../interfaces';
import { DrumService } from '../services';

const getSoundFileFn = () => {
  const assetPath = getFullAssetPath();
  return (description: string) => `${assetPath}sounds/${description}.wav`;
}

const drumKeyTranstionEnd = () => 
  fromEvent(getHostNativeElement(), 'transitionend')
    .pipe(
      filter(evt => evt instanceof TransitionEvent),
      map(evt => evt as TransitionEvent),
      filter(evt => evt.propertyName === 'transform')
    );

@Component({
  standalone: true,
  selector: 'app-drum-key',
  template: `
    <ng-container>
      <kbd>{{ entry.key }}</kbd>
      <span class="sound">{{ entry.description }}</span>
      <audio [src]="soundFile" #audio></audio>
    </ng-container>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DrumKeyComponent implements OnDestroy {
  @Input() 
  entry!: Key;

  @ViewChild('audio', { static: true })
  audio: ElementRef<HTMLAudioElement> | undefined;

  @HostBinding('class.playing') isPlaying = false;

  cdr = inject(ChangeDetectorRef);
  playSoundSubscription = inject(DrumService).playDrumKey$
    .pipe(filter(key => key === this.entry.key))
    .subscribe(() => this.playSound());
  transitionSubscription = drumKeyTranstionEnd()
    .subscribe(() => {
      this.isPlaying = false;
      this.cdr.markForCheck();
    });
  getSoundFile = getSoundFileFn();

  get soundFile() {
    return this.getSoundFile(this.entry.description);
  }

  playSound() {
    if (!this.audio) {
      return;
    }

    const nativeElement = this.audio.nativeElement;
    nativeElement.currentTime = 0;
    nativeElement.play();
    this.isPlaying = true;
    this.cdr.markForCheck();
  }

  ngOnDestroy(): void {
    this.playSoundSubscription.unsubscribe();
    this.transitionSubscription.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

DrumKeyComponent constructs playSoundSubscription and transitionSubscription subscriptions to play the actual sound and display a yellow border until the sound ends. Using inject operator, I construct these subscriptions outside of constructor and ngOnInit.

Declare drum service to pass data from Drum to DrumKey component

When DrumComponent observes the correct key is pressed, the key must emit to DrumKeyComponent to perform CSS animation and play sound. The data is emit to Subject that is encapsulated in DrumService.

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class DrumService {
  private readonly playDrumKey = new Subject<string>();
  readonly playDrumKey$ = this.playDrumKey.asObservable();

  playSound(key: string) {
    this.playDrumKey.next(key);
  }

  getEntryStore() {
    const entries: Key[] = [
      {
        key: 'A',
        description: 'clap'
      },
      {
        key: 'S',
        description: 'hihat'
      },
      {
        key: 'D',
        description: 'kick'
      },
      {
        key: 'F',
        description: 'openhat'
      },
      {
        key: 'G',
        description: 'boom'
      },
      {
        key: 'H',
        description: 'ride'
      },
      {
        key: 'J',
        description: 'snare'
      },
      {
        key: 'K',
        description: 'tom'
      },
      {
        key: 'L',
        description: 'tink'
      }
    ];

    return {
      entries,
      allowedKeys: entries.map(entry => entry.key),
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Use RxJS and Angular to implement key down observable

Declare subscription instance member, assign the result of windowKeydownSubscription to it and unsubscribe in ngDestroy()

subscription = windowKeydownSubscription();

ngOnDestroy(): void {
    this.subscription.unsubscribe();
}
Enter fullscreen mode Exit fullscreen mode
// drum.component.ts

const windowKeydownSubscription = () => {
  const drumService = inject(DrumService);
  const allowedKeys = getEntryStore().allowedKeys;
  return fromEvent(inject<Window>(WINDOW), 'keydown')
    .pipe(
      filter(evt => evt instanceof KeyboardEvent),
      map(evt => evt as KeyboardEvent),
      map(({ key }) => key.toUpperCase()),
      filter(key => allowedKeys.includes(key)),
    ).subscribe((key) => drumService.playSound(key));
}
Enter fullscreen mode Exit fullscreen mode
  • fromEvent(inject(WINDOW), 'keydown') - observe keydown event on native window
  • filter(evt => evt instanceof KeyboardEvent) - filter event is an instance of KeyboardEvent
  • map(evt => evt as KeyboardEvent) - cast event to KeyboardEvent
  • map(({ key }) => key.toUpperCase()) - convert key to uppercase
  • filter(key => allowedKeys.includes(key)) - validate key can play sound
  • subscribe((key) => drumService.playSound(key)) - subscribe the observable to play the wav file Use RxJS and Angular to implement play sound file
// drum-key.component.ts

const drumKeyTranstionEnd = () => 
  fromEvent(getHostNativeElement(), 'transitionend')
    .pipe(
      filter(evt => evt instanceof TransitionEvent),
      map(evt => evt as TransitionEvent),
      filter(evt => evt.propertyName === 'transform')
    );

playSoundSubscription = inject(DrumService).playDrumKey$
    .pipe(filter(key => key === this.entry.key))
    .subscribe(() => this.playSound());

transitionSubscription = drumKeyTranstionEnd()
    .subscribe(() => {
      this.isPlaying = false;
      this.cdr.markForCheck();
    });
Enter fullscreen mode Exit fullscreen mode

Let's demystify playSoundSubscription

  • inject(DrumService).playDrumKey$ - observe playDrumKey$ observable of DrumService
  • filter(key => key === this.entry.key) - compare component's key and the key pressed, and they are the same
  • subscribe(() => this.playSound()) - play the wav file

Let's demystify drumKeyTranstionEnd and transitionSubscription

  • fromEvent(getHostNativeElement(), 'transitionend')- observe transition event of the host element
  • filter(evt => evt instanceof TransitionEvent) - filter event is an instance of TransitionEvent
  • map(evt => evt as TransitionEvent) - cast event to TransitionEvent
  • filter(evt => evt.propertyName === 'transform') - filter the event property is transform
  • subscribe(() => { this.isPlaying = false; this.cdr.markForCheck(); }) - subscribe the observable to update host class to display yellow border until the sound stops

This is it, we have created a drum kit that plays sound after pressing key.

Final Thoughts

In this post, I show how to use RxJS and Angular standalone components to create a drum kit. The application has the following characteristics after using Angular 15's new features:

  • The application does not have NgModules and constructor boilerplate codes.
  • Apply inject operator to inject services in const functions outside of component classes. The component classes are shorter and become easy to comprehend.
  • In DrumKeyComponent, I assign subscriptions to instance members directly and don't have to implement OnInit lifecycle hook.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .