Terminal emulation of DEC-PS

Terminals are awesome. As a young lad enthusiastic about computers these mysterious devices fascinated me. With their strange keyboards and unknown interface ports they were reminiscent of computers, but not quite the same. I was a bit too young to understand or appreciate them at the time, and by then they were probably already passed their golden era and awfully close to general purpose computers, with local storage, built-in word processors and even entire operating systems!

Today most of us associate the word terminal with software that emulates a terminal, a terminal emulator. And if we dig a tad deeper we’ll find that our terminal emulators emulate specific parts of specific terminals. Usually the DEC VT-100 and the DEC VT-220.

HP 2626A

HP2626A Wordprocessor

TS-8xx

CP/M on a TS-8xx

VT100

VT-100 by Jason Scott

VT200

DEC VT-220

In many ways I would consider them peak terminal as I don’t think any terminal after them has received the same level of fame. Although, in recent years, we are seeing implementations of standards such as the VT-320 Sixel in modern terminal emulators.

One standard that seems to be mostly forgotten, or ignored, is the DEC VT-520 DECPS, also known as the DEC Play Sound. I say ignored because it is actually stubbed in VTE (Gnome Terminal) with the following comment:

void
Terminal::DECPS(vte::parser::Sequence const& seq)
{
        /*
         * DECPS - play sound
         *
...
         * References: VT525
         *
         * Probably not worth implementing.
         */
}

What a shame, I’ve always felt the terminal bell a bit limited, so let’s implement DEC-PS in my preferred terminal emulator, alacritty.

Specification

A DECPS command is sent via an ANSI Control Sequence Indicator (CSI) in the format:

ESC [ *volume* *duration* *note* , ~

Volume

The volume is an integer within the range [0, 7] where - 0 is muted - 1..3 is low volume - 4..7 is high volume

Duration

The duration of the note in 1/32 parts of a second. So 32 is 1 second.

Notes

The register of notes is represented with the interval [1, 25] and ranges from C5 to a C7. The specification specifies a few example frequencies, but they appear to be incorrect.

The VT-520 DECPS is extremely vague on implementation details, and I sadly don’t have a DEC VT-520 lying around. There is no information on whether or not playing a sequence blocks the terminal or if multiple notes are supported within the same escape sequence. The terminal does specify it has a buffer storing up to 16 notes, so we’ll assume the following:

Implementation

The Alacritty/VTE source code is wonderfully organized, so we can start by adding a play_sound method to the Handler trait in vte/src/ansi.rs

fn play_sound(&mut self, _: u16, _:u16, _:Vec<u16>) {}

and a parse block for the sequence, in the same file, alongside the other CSI parsers:

('~', [b',']) => handler.play_sound(
    next_param_or(0),
    next_param_or(0),
    params_iter.map(|param| param[0]).collect(),
),

That’s actually all we need to do in VTE, now we just need to add an implementation of Handler::play_sound in alacritty, which I’ll put with the rest of the trait implementation in alacritty_terminal/src/term/mod.rs:

use rodio::source::{SineWave, Source};
use rodio::Sink;
use std::time::Duration;

use std::thread;

// Inside pub struct Term<T>
    play_tx: Sender<Vec<u16>>,


// Inside impl<T> Term<T>
    pub fn new<D: Dimensions>(config: &Config, dimensions: &D, event_proxy: T) -> Term<T> {
        // ...
        let (tx, rx) = mpsc::channel();

        thread::spawn(move || {
            let (_stream, stream_handle) = rodio::OutputStream::try_default().unwrap();
            let sink: Sink = Sink::try_new(&stream_handle).unwrap();
            loop {
                let settings: Vec<u16> = match rx.recv() {
                    Ok(data) => data,
                    Err(_) => break,
                };

                let notes = rx.recv().unwrap();

                sink.set_volume((1.0 as f32).min(1.0 / 7.0 * settings[0] as f32));

                let freq = 523.0;
                for note in notes {
                    let adjust_freq = if note > 0 && note <= 26 {
                        freq * f32::powf(2.0, note as f32 / 12.0)
                    } else {
                        0.0
                    };
                    let source = SineWave::new(adjust_freq)
                        .take_duration(Duration::from_secs_f32(settings[1] as f32 / 32.0))
                        .delay(Duration::from_millis(31));
                    sink.append(source);
                }
                sink.sleep_until_end();
            }
        });

         Term {
             // ...
            play_tx: tx,
         }
    }

    // ...
    #[inline]
    fn play_sound(&mut self, volume: u16, duration: u16, notes: Vec<u16>) {
        self.play_tx.send(vec![volume, duration]).unwrap();
        self.play_tx.send(notes).unwrap();
    }

So to explain what’s going on here, we create a separate play thread and send it instructions for playing via message passing. Inside the thread we have some scaffolding to create a rodio output and sink. The actual magic happens in this section:

    sink.set_volume((1.0 as f32).min(1.0 / 7.0 * settings[0] as f32));
    let freq = 523.0;
    for note in notes {
        let adjust_freq = if note > 0 && note <= 26 {
            freq * f32::powf(2.0, note as f32 / 12.0)
        } else {
            0.0
        };
        let source = SineWave::new(adjust_freq)
            .take_duration(Duration::from_secs_f32(settings[1] as f32 / 32.0))
            .delay(Duration::from_millis(31));
        sink.append(source);

where the first line clamps our 8 levels to a rodio compatible volume range between [0.0, 1.0]. We then iterate our notes and for each note above C5 (523Hz) we increase the frequency with increments of a twelfth root of 2, which is the distance between semitones in twelve-tone equal temperament (like on a well tempered clavier). We create a sine wave with this frequency and append it to our sink and out comes sound 🎵.

Where to go from here

The potential is limitless. With play sound our terminal experience can be so much better, here are some videos demonstrating this new universe of possibilities, allowing better programs, better games and a whole new level of interactivity!

sl

cat surprise

Pong

Code

Code for this atrocitty alacritty is available on GitHub.