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.
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
::DECPS(vte::parser::Sequence const& seq)
Terminal{
/*
* 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:
- We can queue notes without blocking
- Multiple notes in the same CSI sequence is permitted.
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(
(0),
next_param_or(0),
next_param_or(.map(|param| param[0]).collect(),
params_iter, )
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>
: Sender<Vec<u16>>,
play_tx
// 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();
.set_volume((1.0 as f32).min(1.0 / 7.0 * settings[0] as f32));
sink
let freq = 523.0;
for note in notes {
let adjust_freq = if note > 0 && note <= 26 {
* f32::powf(2.0, note as f32 / 12.0)
freq } 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));
.append(source);
sink}
.sleep_until_end();
sink}
});
{
Term // ...
: tx,
play_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:
.set_volume((1.0 as f32).min(1.0 / 7.0 * settings[0] as f32));
sinklet freq = 523.0;
for note in notes {
let adjust_freq = if note > 0 && note <= 26 {
* f32::powf(2.0, note as f32 / 12.0)
freq } 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));
.append(source); sink
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.