Background

How to Play Sounds on Multichannel Mixer Using Tone.js and WebAudio

A practical guide to routing audio through multiple channels in web browsers using Tone.js and WebAudio API

Ever tried playing different sounds through specific outputs in your web app? Like sending the kick drum to outputs 1-2 and vocals to 3-4? If you've worked with professional audio interfaces like Allen & Heath PX5, you know this should be straightforward. But in web browsers? Not so much! 🎧 This article shares the audio routing solutions I developed for EXØ_LAB, my digital audio-visual performance platform.

The Challenge: Browser Audio is Stereo-Only by Default

Here's what we're dealing with: Modern DJ software like Traktor, Serato, or Rekordbox lets you route different decks to different outputs. For example, you might want to preview the next track in your headphones while the current track plays through the main speakers.

In my setup, I'm using the Allen & Heath PX5 mixer which has 5 stereo channels (that's 10 channels when routing audio):

  • Channels 1-4 are standard fader channels for your decks
  • Channel A is a special channel for connecting standalone devices like Native Instruments Maschine

But here's the catch: web browsers are designed with simple stereo output in mind - just your regular left and right channels. The browser's default audio routing isn't built for this kind of professional DJ setup. But don't worry - I've got a solution that works beautifully with professional audio interfaces! 🎛️

The Magic Behind Channel Splitting and Merging

Think about this: humans have two ears, so it's only natural that our audio devices cater to this with two speakers—one for the left ear and one for the right. This is so commonplace in our lives—headphones, computer speakers, TVs, monitors—that we might never give it a second thought. But if you stop and consider it, most music and audio tracks we listen to are designed as two separate streams, one for each speaker. This stereo arrangement is so deeply ingrained in how we consume sound that it often goes unnoticed.

However, this division becomes critical when working with audio mixers. These devices, whether physical hardware or virtual software, deal explicitly with the concept of left and right audio channels. Each physical mixer channel—the fader you slide up and down—actually handles two audio channels: one for the left and one for the right. This is exactly the challenge I faced when building the audio engine for EXØ_LAB.

In the world of audio programming, routing stereo audio to a physical mixer channel involves a few key steps:

  1. Splitting the stereo track: Audio is separated into its left and right channels to work with them independently.
  2. Merging the channels: These two channels are then combined into a format that matches the mixer's expectations.
  3. Guiding the output: Finally, the merged audio is routed to a specific physical mixer channel.

This process illustrates how audio programming operates on both a high level—dealing with tracks, speakers, and mixer channels—and a low level, where individual audio streams are manipulated to achieve the desired outcome.

When you're writing audio software, such as routing a stereo track to a mixer channel using the WebAudio API or Tone.js, these steps become explicit. You're not just "sending" audio to the mixer; you're directing and reshaping it at a fundamental level to match the physical infrastructure of the audio system.

Understanding these concepts bridges the gap between everyday listening and professional audio workflows. It's what makes the seemingly simple act of playing a song through specific speakers a fascinating technical challenge—and why working with tools like WebAudio and Tone.js feels like uncovering the magic of sound engineering. 🎧

The Solution: Tone.js + WebAudio Channel Routing

Let's make this happen! First, we need to set up our AudioContext with the right channel configuration. If you're interested in how I approach software architecture and clean code practices in audio applications, check out my article on engineering AI communication where I discuss similar principles.

interface AudioContextConfig {
  channelCount: number;
}
 
const setupAudioContext = (config: AudioContextConfig): AudioContext => {
  const audioContext = new AudioContext();
  audioContext.destination.channelInterpretation = "discrete";
  audioContext.destination.channelCountMode = "explicit";
  audioContext.destination.channelCount = config.channelCount;
 
  // Initialize Tone.js with our context
  Tone.setContext(audioContext);
  
  return audioContext;
};
 
const audioContext = setupAudioContext({ channelCount: 10 });

Now for the interesting part - our channel routing system:

interface AudioManagerConfig {
  audioContext: AudioContext;
}
 
class AudioManager {
  private readonly audioContext: AudioContext;
  private channelSplitters: ChannelSplitterNode[] = [];
  private sounds: Map<string, Tone.Player> = new Map();
 
  constructor(config: AudioManagerConfig) {
    this.audioContext = config.audioContext;
  }
 
  async createChannelMergers(): Promise<void> {
    // Get the number of outputs available on the sound card
    const numOutputs = this.audioContext.destination.maxChannelCount;
    this.audioContext.destination.channelCount = numOutputs;
 
    // Create ChannelSplitterNodes for each stereo pair
    for (let i = 0; i < numOutputs / 2; i++) {
      const splitter = this.audioContext.createChannelSplitter(2);
      this.channelSplitters.push(splitter);
    }
 
    // Create a merger to combine all channels
    const channelMerger = this.audioContext.createChannelMerger(numOutputs);
    channelMerger.connect(this.audioContext.destination);
 
    // Connect splitters to specific channel pairs in the merger
    for (let i = 0; i < numOutputs / 2; i++) {
      const [firstChannel, secondChannel] = [i * 2, (i * 2) + 1];
      this.channelSplitters[i].connect(channelMerger, 0, firstChannel);
      this.channelSplitters[i].connect(channelMerger, 1, secondChannel);
    }
  }
 
  async listAudioDevices(): Promise<MediaDeviceInfo[]> {
    try {
      // Request permission first
      await navigator.mediaDevices.getUserMedia({ audio: true });
      
      const devices = await navigator.mediaDevices.enumerateDevices();
      const audioOutputs = devices.filter(device => device.kind === 'audiooutput');
      
      console.log('Available audio outputs:', audioOutputs);
      return audioOutputs;
    } catch (error) {
      console.error('Failed to list audio devices:', error);
      throw error;
    }
  }
 
  async selectAudioOutput(deviceId: string): Promise<void> {
    try {
      // Check if the browser supports setSinkId
      if (!('setSinkId' in AudioContext.prototype)) {
        throw new Error('setSinkId is not supported in this browser');
      }
 
      // Get audio element (in this case we're using Tone.js context)
      const audioElement = Tone.getContext().destination.context;
      
      // Set the audio output device
      await audioElement.setSinkId(deviceId);
      
      console.log(`Audio output set to device: ${deviceId}`);
    } catch (error) {
      console.error('Failed to set audio output:', error);
      throw error;
    }
  }
 
  // Example usage within the class
  async selectPX5Interface(): Promise<void> {
    const devices = await this.listAudioDevices();
    const px5Device = devices.find(device => device.label.includes('PX5'));
 
    if (px5Device) {
      await this.selectAudioOutput(px5Device.deviceId);
    } else {
      throw new Error('PX5 interface not found');
    }
  }
}
 
// Example usage:
const audioManager = new AudioManager({ audioContext });
await audioManager.selectPX5Interface();

Important Note About Transport

Let me share a tricky issue I encountered with Tone.js. Everything looked perfect in my code - Transport started, timer was increasing, audio was connected to the channels. But... silence. Complete silence. No errors, nothing in the console, just silence. 🤔 This is what I call a "false positive" - a situation I also discuss in my article about letting your code speak to you.

The solution? Always use getTransport():

// ❌ This should work according to docs, but can be silent
Tone.Transport.start();
 
// ✅ This has proven to work reliably
const transport = Tone.getTransport();
transport.start();

Dynamic Channel Scheduling

One of the powerful features of this system is the ability to schedule audio playback and channel routing in real-time. Here's how to implement dynamic scheduling:

// We'll inject transport to make testing easier
interface PerformanceControllerConfig {
  audioManager: AudioManager;
  transport: Tone.Transport;
}
 
class PerformanceController {
  private audioManager: AudioManager;
  private transport: Tone.Transport;
 
  constructor(config: PerformanceControllerConfig) {
    this.audioManager = config.audioManager;
    this.transport = config.transport;
  }
 
  // Handle channel change from UI, keyboard, MIDI, etc.
  handleChannelChange(trackId: string, targetChannel: number) {
    // Get next safe time to make the switch (e.g., next 16th note)
    const quantizeTime = this.transport.nextSubdivision('16n');
    
    // Add a tiny offset to ensure smooth transition
    const switchTime = quantizeTime + 0.01;
 
    try {
      this.audioManager.rescheduleToChannel(trackId, targetChannel, switchTime);
      console.log(`Track ${trackId} scheduled to channel ${targetChannel} at ${switchTime}`);
    } catch (error) {
      console.error('Failed to switch channels:', error);
      // Handle the error in UI (show notification, etc.)
    }
  }
 
  // Preview channel routing (useful for headphone preview)
  previewChannel(trackId: string, previewChannel: number) {
    const now = this.transport.now();
    this.audioManager.rescheduleToChannel(trackId, previewChannel, now);
  }
}
 
// Example usage with dependency injection for better testing
const performanceController = new PerformanceController({
  audioManager: new AudioManager({ /* config */ }),
  transport: Tone.getTransport()
});
 
// In tests, you can mock the transport:
const mockTransport = {
  nextSubdivision: jest.fn().mockReturnValue(1),
  now: jest.fn().mockReturnValue(0)
};
 
const testController = new PerformanceController({
  audioManager: mockAudioManager,
  transport: mockTransport as unknown as Tone.Transport
});

This implementation allows you to:

  1. Schedule precise playback timing using Tone.js Transport
  2. Move audio between channels without interrupting playback
  3. Synchronize multiple channel changes
  4. Maintain timing relationships between different audio sources
  5. Handle real-time interactions from various input sources (UI, keyboard, MIDI)
  6. Preview channel routing before committing changes
  7. Write reliable tests with dependency injection

The key here is that we're quantizing the channel switches to musical subdivisions (like 16th notes) to keep everything in time with the music. We also add a tiny offset to ensure smooth transitions and handle any potential errors that might occur during the switch.

Need Help?

Are you implementing multichannel audio in your web app? Running into issues? I'd love to help! Just head over to my contact page, and let's solve those audio routing challenges together. Whether it's a bug you can't squash or you need clarification on the concepts, I'm here to help! If you're interested in the development process and architecture behind audio applications, you might also enjoy reading about my focus and coding music setup. 🤝

Comments