Telegraph with RxJS: the power of reactive systems — figure 1 ## Morse Code Fundamentals Telegraph relies on Morse code, which is essentially a binary signal pattern. The signal is split into discrete segments, each representing either a "beep" (1) or silence (0). The rules are straightforward: - Two symbols exist: dash and dot - A dash consists of three consecutive 1s - A dot is a single 1 - Symbols are divided by a single 0 - Letters are separated by three consecutive 0s - Words use seven consecutive 0s (the four 0s for space plus the three 0s ending a character) Each letter corresponds to a specific pattern, as shown in the table. The black squares denote 1s and white squares denote 0s. Consider the letter "H"—it translates to four dots. Since each dot is a single 1 and these must be separated by 0s, we get: **1010101**. Following this logic, "HELLO WORLD" would be encoded as: **1010101**000**1**000**101110101**000**101110101**000**11101110111**0000000 = **HELLO**[end of word] **101110111**000**11101110111**000**1011101**000**101110101**000**1110101**0000000 = **WORLD**[end of word] This reflects the standard US Morse code table, though alternative versions exist. For an Angular implementation, the code table is an ideal candidate for an `[InjectionToken](https://angular.io/api/core/InjectionToken)`. Using the second parameter with a factory function allows us to set a default implementation, which projects can later override with their own Morse code tables:
export const MAP = new InjectionToken<Map<string, readonly (0 | 1)[]>>(
  'Morse code dictionary',
  {
    factory: () => new Map([
      [' ', [0, 0, 0, 0]],
      ['a', [1, 0, 1, 1, 1]],
      // ...
    ])
  }
)
Our goal is to let users type on a keyboard, encode those letters into Morse code sequences, transmit them over a simulated telegraph line, and then decode and display them. This entire process begins with a single `fromEvent(document, 'keydown')` source, from which we'll branch out extensively! ## Encoding Process Encoding letters can be done synchronously—each letter instantly maps to an array of 0s and 1s. However, since we're emulating an analog device that relies on signal duration, we'll assign a specific length to each unit. > We'll also make the unit duration an `InjectionToken` for easy customization! This means letters get translated into sequences with a **duration**. To maintain emission order and never lose a character from a previous emission, we'll use the `concatMap` operator. This belongs to the Higher Order Observables family. It maps each value to an `Observable`, and when a new value arrives (like a key press), it waits for the previous `Observable` to complete before emitting the next sequence. This ensures rapid typing doesn't result in lost letters. For demonstration purposes, we'll also create a service to send these sequences, enabling interaction via mouse or an on-screen virtual keyboard:
@Injectable({
  providedIn: 'root'
})
export class MorseService extends Subject<readonly (0 | 1)[]> {
  constructor(
    @Inject(MAP) private readonly chars: Map<string, readonly (0 | 1)[]>
  ) {
    super();
  }

  send(char: string) {
    this.next(this.chars.get(char));
  }
}
Now we can construct a token that converts letters into Morse code sequences. The process involves injecting the mapping table, unit duration, and the aforementioned service. We'll map all `keydown` events from `document` to their corresponding sequences, then merge these with the service emissions. Using `concatMap`, we'll losslessly convert everything into a stream of 0s and 1s, terminating each character sequence with a space sequence (three consecutive 0s):
export const MORSE = new InjectionToken<Observable<0 | 1>(
  'A sequence of Morse code', 
  {
    factory: () => {
      const chars = inject(MAP);
      const duration = inject(UNIT);
      const service$ = inject(MorseService);
      const keydown$ = fromEvent(inject(DOCUMENT), 'keydown').pipe(
        map(({ key }: KeyboardEvent) => chars.get(key)),
        filter(Boolean)
      );
	
      return merge(service$, keydown$).pipe(
        concatMap(sequence =>
          from(sequence).pipe(
            endWith(...SPACE),
            delayEach(duration)
          )
        ),
        share(),
      );
    }
  }
);
Notice we use `from` instead of `of`. When dealing with *Arrays*, `from` converts them into individual emissions, whereas `of` treats the entire array as a single value. Within this setup, there's a custom operator called `delayEach`. Unlike `delay` which postpones the entire stream, this operator delays each individual emission from the `Observable`:
export function delayEach<T>(duration: number): MonoTypeOperatorFunction<T> {
  return concatMap(x => of(x).pipe(delay(duration)));
}
To listen to the resulting Morse code, we simply inject the `MORSE` token and subscribe! ## Decoding Challenges Decoding is where things get interesting. We need to create an operator that compares incoming values against a sequence. It should reset if a value doesn't match the digit at the corresponding position, emit a letter when the sequence completes (followed by three 0s to mark the end), and then repeat the pattern. We terminate and restart the stream to keep emission indices aligned with our sequence. To visualize this, we'll build a dedicated module for characters, comprising a *Component*, *Directive*, and *Service*. This separation ensures clean logic. We need to track the current character, but it's required in both the *Component* and *Service*. To avoid circular dependencies, we'll create a *Directive* with a single purpose—exposing the character:
@Directive({
  selector: '[char]'
})
export class CharDirective {
  @Input() char = '';
}
The same selector can be used for a *Component*. This component will handle signal sending via mouse clicks and serve as our visualization:
@Component({
  selector: '[char]',
  templateUrl: 'char.template.html',
  styleUrls: ['char.style.less'],
  providers: [CharService]
})
export class CharComponent {
  constructor(
    @Inject(CharService) readonly service: Observable<number | string>,
    @Inject(CharDirective) readonly directive: CharDirective,
    @Inject(MorseService) private readonly emitter: MorseService
  ) {}

  @HostListener('click')
  onClick() {
    this.emitter.send(this.directive.char);
  }
}
Next, we implement the actual decoding logic. The `CharService` is typed as `Observable`. It emits decoding progress as numbers between 0 and 1, and once decoding completes, it emits the actual letter. This progress visualization will be displayed for each character in the demo. ## Building the Service Creating this service is the trickiest part. It took considerable effort to figure out the right RxJS approach, mainly due to the space character. The space character's pattern—being a series of 0s—looks similar to the character-end sequence, causing confusion. We'll start by defining all the helper functions. Our service extends `Observable` and maintains a private internal stream based on the Morse code `Observable` we created earlier:
@Injectable()
export class CharService extends Observable<number | string> {
  private readonly inner$ = this.morse$.pipe(
    // ...
  )

  constructor(
    @Inject(MORSE) private readonly morse$: Observable<0 | 1>,
    @Inject(MAP) private readonly chars: Map<string, readonly (0 | 1)[]>,
    @Inject(CharDirective) private readonly directive: CharDirective
  ) {
    super(subscriber => this.inner$.subscribe(subscriber));
  }
}
The sequence we're searching for comes from a *getter* rather than a *read-only property*, because at construction time, the **Directive input hasn't been processed yet**:
private get sequence(): readonly (0 | 1)[] {
  return [...this.chars.get(this.directive.char), ...SPACE];
}
Had the sequence been a static string and not an input, we could have used `@Attribute` in the *Directive* and accessed it during construction. However, this approach won't work in a `*ngFor` scenario. One optimization is to avoid **recreating the `Array`** every time we access it. For this, we can use the `@tuiPure` decorator from Taiga UI. This library frequently employs it for lazy getters. It's a *memoization* pattern for deferred computation—the first time the getter is accessed, it's replaced with a plain property containing the result. Simply adding the decorator above the getter handles this. The decorator also works on methods, checking if parameters match the previous call and returning the cached result without re-executing. We also need a helper to verify that the value at a specific index of the sequence matches the stream value:
private isValid(value: number, index: number): boolean {
  return this.sequence[index] === value;
}
Next, we define what the service should emit—we want both decoding progress and the decoded letter:
private getValue(index: number): number | string {
  return this.sequence.length === index + 1 
    ? this.char 
    : (index + 2) / this.sequence.length;
}
With these helpers in place, we can assemble the internal stream:
private readonly inner$ = this.morse$.pipe(
  takeWhile(this.isValid.bind(this)),
  map((_, index) => this.getValue(index)),
  startWith(0),
  endWith(0),
  takeWhile(isNumber, true),
)
The stream uses two `takeWhile` operators. The first terminates the stream on a sequence mismatch. The second stops it once a letter has successfully passed through (`isNumber` is a type-checking helper). > Note the second argument for `takeWhile`—it allows the value that triggered the termination condition to pass through as well. ## Stream Restart Logic Terminating and restarting the stream simplifies sequence matching—it keeps emission indices aligned with the target sequence. After matching the entire sequence and emitting the letter (caught by the second `takeWhile`), we simply restart. However, if decoding fails, we must wait for a new character, meaning **three consecutive 0s** to indicate the character boundary. My initial instinct for handling failed restarts was to use `repeatWhen`. The catch is that `repeatWhen`'s factory function is invoked only once, upon the first termination. After that, it continuously listens to the `Observable` it returned:
repeatWhen(() => threeZeroes$),
This works for the second and all subsequent restarts. But on the first restart attempt, those *three* 0s marking the letter's end have **already been consumed**, so they're missed. First, we add logic to listen for *three consecutive 0s*. The `scan` operator is perfect for this—it's like `reduce` for `Array` but works dynamically (RxJS's `reduce` only fires on completion).
export function consecutive<T>(
  value: T,
  amount: number
): OperatorFunction<T, unknown> {
  return pipe(
    startWith(...new Array(amount).fill(value)),
    scan((result, item) => (item === value ? ++result : 0), 0),
    filter(v => v >= amount),
  );
}
We kick things off with `startWith` initialized to three 0s, then count consecutive 0s, letting only groups of three or more through. Using *exactly* three would cause issues, as the space character (seven 0s) would interfere with other letters. Now we need to separate the two `takeWhile` operators. The first should terminate an *inner* stream, which means we're back to Higher Order Observables. `concatMap` comes to the rescue again:
private readonly inner$ = this.morse$.pipe(
  consecutive(0, SPACE.length),
  concatMapTo(this.morse$.pipe(
    takeWhile(this.isValid.bind(this)),
    map((_, index) => this.getValue(index)),
    startWith(0),
    endWith(0),
  )),
  takeWhile(isNumber, true),
  repeat(),
)
When the inner stream gets terminated due to a mismatch, *three consecutive 0s* trigger a restart. A space character produces multiple groups of *three consecutive 0s*, but they all wait for the inner stream to complete and get discarded once the space character resets the entire stream via the second `takeWhile`.

Live Demonstration

Telegraph with RxJS: the power of reactive systems — figure 2

All the pieces are in place, so let's build a working example. The app will capture keyboard input and render a virtual keyboard on screen. Each key will show the decoding progress as the message is being received, and the full text will be displayed below.

A Morse code telegraph wouldn't feel right without the characteristic beeping. Generating simple tones is straightforward using the Web Audio API, specifically with OscillatorNode. My wrapper library, @ng-web-apis/audio, makes this even easier to integrate.

This library is part of the Web APIs for Angular open-source project. Our mission is to provide high-quality, lightweight wrappers for native browser APIs so they can be used naturally within Angular applications. Take a look at the full collection of wrappers we've published.

Using these wrappers, we can construct an audio graph directly from declarative Angular directives:

<ng-container waOscillatorNode autoplay frequency="523">
  <ng-container
    waGainNode
    gain="0"
    [gain]="morse$ | async | waAudioParam : 0.02"
  >
    <ng-container waAudioDestinationNode></ng-container>
  </ng-container>
</ng-container>

This configuration produces a tone for one second and then silence, all managed by adjusting the volume. Next, we need to define the layout for both the virtual keyboard and the decoded output. Adding a «Clear» button to reset everything also seems practical:

<section>
  <button *ngFor="let char of chars" type="button" [char]="char">
    {{char}}
  </button>
</section>
<output>{{ output$ | async }}</output>
<footer>
  <button type="reset" (click)="reset$.next()">Clear</button>
</footer>

The question is: where does the output$ stream come from? The answer lies in querying services directly from the template. It's worth noting that Angular queries are capable of retrieving services and other instances from node injectors.

@ViewChildren(CharService)
readonly services: QueryList<Observable<string | number[]>>

Just remember, these queried instances won't be accessible until the ngAfterViewInit lifecycle hook fires!

We can assemble the output$ stream using this approach:

readonly reset$ = new Subject<void>();

readonly output$ = this.reset$.pipe(
  switchMap(() => merge(...this.services.toArray()).pipe(
    filter(x => !isNumber(x)),
    scan((result, char) => result + char, ''),
    startWith(''),
  )),
  startWith(''),
);

ngAfterViewInit() {
  this.reset$.next();
}

To visualize the decoding progress, we'll add a simple span element inside the CharComponent. Its height and color can be driven by two separate streams:

readonly progress$ = this.service.pipe(
  filter(isNumber),
  map(value => value * 100),
);

readonly pending$ = this.service.pipe(
  filter(Boolean),
  map(isNumber),
);

One final technique worth highlighting is leveraging the same token used for the UNIT duration to control CSS transitions. With the Ivy renderer in place, Angular supports binding directly to CSS variables. So, we can define a --duration variable in our styles and wire it up in the main component:

@Component({
  // ...
  host: {
    '[style.--tui-duration]': 'unit + "ms"',
  },
})
export class AppComponent implements AfterViewInit {
  constructor(
    // ...
    @Inject(UNIT) readonly unit: number,
  ) {}

  // ...
}

That wraps up the implementation. Feel free to explore the final working demo!