badterm-core 0.4.5

Terminal emulation, telnet protocol and dialing directory for the BadTerm BBS client
Documentation
//! IGS's sound (see [`crate::igs`]): GIST sound effects, chip music, the
//! ST's sound chip driven directly, and MIDI notes, rendered to sound for
//! the audio channels (see [`crate::audio`]).
//!
//! The effects are Lee Actor's GIST ("G.I. Sound Tool") sounds that IG
//! built in; their data is from IcyTerm (crates/icy_engine_gui/src/music/
//! sound_effects.rs, <https://github.com/mkrueger/icy_tools>, Apache
//! License 2.0). They are played by the `ym2149-gist-replayer` crate (MIT)
//! on its YM2149 emulator, as the ST's chip played them.

use ym2149::{Ym2149, Ym2149Backend};
use ym2149_gist_replayer::{GistPlayer, GistSound};

use crate::audio::{Pcm, RATE};

/// The effects' names, as IG's notes give them.
pub const NAMES: [&str; 20] = [
    "Alien Invasion",
    "Red Alert",
    "Gunshot",
    "Laser 1",
    "Jackhammer",
    "Teleport",
    "Explosion",
    "Laser 2",
    "Longbell",
    "Surprise",
    "Radio Broadcast",
    "Bounce Ball",
    "Eerie Sound",
    "Harley Motorcycle",
    "Helicopter",
    "Steam Locomotive",
    "Wave",
    "Robot Walk",
    "Passing Plane",
    "Landing",
];

/// The longest a sound is rendered.
const MAX_SECONDS: usize = 12;

pub(crate) static EFFECTS: [[i16; 56]; 20] = [
    // Alien Invasion
    [
        28, 477, -1, 15, 1, 1, 4681, -5, 27853, 5, -9830, -1, -1278, 7, -32768, 7, -32768, 14, 1, -4080, -256, -4080,
        -256, 0, 0, -4080, -256, 18, -30086, 1696, 13405, -1200, -26066, 1696, 13405, -1200, -26066, -1200, -26066, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Red Alert
    [
        200, 477, -1, 15, 1, 15, 0, -1, -1267, 10, 3277, -1, -2573, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 86,
        2926, -8, 27220, 7, -16776, -85, -17949, -8, 27220, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Gunshot
    [
        8, 1250, 31, 15, 1, 3, -16384, -1, -7562, 0, 0, -1, -10923, 0, 0, 1, 19988, 0, 1, -4080, -256, -4080, -256,
        114, -4444, -57, -24712, 56, 24712, 0, 0, -3584, -8192, 28665, 0, 0, 0, -3584, -8192, 0, 1, -3, -14043, -31, 0,
        31, 0, 0, 0, 1, -29491, 0, 0, 0, 0, 0,
    ],
    // Laser 1
    [
        8, 284, -1, 15, 1, 3, -16384, -1, -8192, 0, 0, -1, -8937, 0, 0, 0, 0, 0, 1, -4057, 2971, -4057, 2971, 338,
        5214, 0, 0, 4056, -2971, 28665, 0, -8, -10895, 57, 21627, -3584, -8192, -8, -10895, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Jackhammer
    [
        8, 625, -1, 15, 1, 3, -16384, -1, -32768, 0, 0, -1, -10923, 5, -19661, 5, -19661, 0, 1, -594, -20719, -2374,
        -17340, 1186, -24098, 0, 0, 2373, 17340, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0,
    ],
    // Teleport
    [
        500, 477, 16, 15, 1, 0, 17873, 0, 0, 15, 0, -1, -410, 1, -22938, 0, -30933, 0, 1, 12, -28542, 1696, 13405, -13,
        18541, -2374, -17340, 0, -26652, 448, -20514, -170, 10530, 188, 29395, -405, -24861, -170, 10530, 0, 1, -1,
        -25395, -31, 0, 0, 3694, 31, 0, -1, -4515, 0, 0, -9, -31562, 0,
    ],
    // Explosion
    [
        200, 477, 31, 15, 1, 15, 0, -1, -1267, 10, 3277, -1, -2573, 0, 0, 0, 0, 0, 1, -9, 18470, -140, -32161, 0, 5504,
        28, 31652, 0, 3679, 1656, 13223, 430, -25398, 430, -25398, -1180, -16857, -307, 25764, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Laser 2
    [
        40, 477, -1, 15, 1, 5, 0, -1, -1248, 10, 3277, -1, -2533, 0, 0, 0, 0, 0, 1, -9, 18470, -140, -32161, 0, 2270,
        -57, -24712, -1, -3337, 1656, 13223, 430, -25398, 430, -25398, -1180, -16857, -307, 25764, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Longbell
    [
        20, 52, -1, 15, 1, 15, 0, -1, -9830, 12, 0, -1, -1024, 1, -32768, 0, 9830, 160, 1, -28, 360, -112, 1442, 3,
        31133, -57, -24712, 0, -11989, 57, 10752, -6, 23743, 5, -18586, -57, -24712, -6, 23743, 4, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Surprise
    [
        100, 477, -1, 15, 1, 3, -16384, -1, -4572, 12, 0, -1, -1966, 1, -32768, 0, 7864, 0, 1, -4080, -256, -4080,
        -256, 10, 12944, 0, 0, 10, 12944, 293, -5746, -264, -17086, 282, 10212, -275, -15067, -264, -17086, 40, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Radio Broadcast
    [
        800, 954, 23, 15, 1, 0, -32768, -1, -69, 14, 6554, -15, -6554, 7, 22938, 5, 19137, 0, 1, -30, -20744, -3665,
        28433, 0, 5618, -3584, -8192, 0, -26399, 10977, -21383, -72, 27596, 263, 30420, -2983, -29831, -72, 27596, 30,
        1, 1, -9175, 29, -15729, -2, 17302, -31, 0, 0, 15049, 15, -32768, -4, -6554, 145,
    ],
    // Bounce Ball
    [
        8, 284, -1, 15, 1, 3, -16384, -1, -8192, 0, 0, -1, -8937, 0, 0, 0, 0, 0, 1, -4057, 2971, -4057, 2971, 338,
        5214, 0, 0, 4056, -2971, 28665, 0, -8, -10895, 57, 21627, -3584, -8192, -8, -10895, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Eerie Sound
    [
        200, 477, -1, 15, 1, 0, -13797, -1, 26214, 14, 26214, -1, -16852, 0, 0, 0, 0, 0, 0, -4, 30423, -29, -18758, 5,
        -22463, 0, 0, 28, 18758, 86, 2926, -11, -7397, 10, 21323, -85, -17949, -11, -7397, 50, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Harley Motorcycle
    [
        500, 1923, 15, 15, 1, 0, 15124, -1, -21, 14, -29491, -15, 29491, 7, -32768, 2, 26214, 0, 1, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 5644, -25803, -1899, 25450, 4515, -20642, -2374, -17340, -1899, 25450, 0, 1, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 9, -5243, -6, 3146, 0,
    ],
    // Helicopter
    [
        500, 62, 15, 15, 1, 0, 15124, -1, -21, 14, -29491, -15, 29491, 2, 6554, 0, -27001, 0, 1, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 12285, 0, -738, -6554, 2948, 26214, -3072, -16384, -738, -6554, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9,
        -5243, -6, 3146, 0,
    ],
    // Steam Locomotive
    [
        800, -1, 23, 15, 1, 0, 9362, -1, -93, 12, 29491, -1, -136, 7, 13107, 1, 25061, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, -9175, 29, -15729, -2, 17302, -31, 0, 0, 15049, 15, -32768, -4,
        -6554, 145,
    ],
    // Wave
    [
        400, 238, -1, 15, 1, 0, 16384, -1, -337, 14, 6554, -1, -154, 5, -9830, 1, -23724, 15, 1, -300, 9867, -1200,
        -26066, 86, 3191, -167, 12227, 0, 1822, 12285, 0, -7, -9339, 24, -28180, -3072, -16384, -7, -9339, 40, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Robot Walk
    [
        8, 1330, -1, 15, 1, 3, -16384, -1, -8192, 0, 0, -1, -8937, 0, 0, 0, 0, 0, 1, -4057, 2971, -4057, 2971, 338,
        5214, 0, 0, 4056, -2971, 28665, 0, -3584, -8192, 28665, 0, -3584, -8192, -3584, -8192, 0, 1, 0, 0, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Passing Plane
    [
        50, 477, -1, 15, 1, 0, 24576, -1, -2048, 11, 16384, -1, -2633, 0, 0, 0, 0, 0, 1, -582, -10279, -4069, -6415,
        54, 25814, -1077, -28428, 4, 9184, 28665, 0, -1577, 27853, 12612, -26214, -3584, -8192, -1577, 27853, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
    // Landing
    [
        700, 78, -1, 15, 1, 0, 24576, -1, -1229, 0, 0, -1, -9830, 0, 0, 0, 0, 0, 1, -3072, -16384, -3072, -16384, 10,
        -9706, 1052, 30787, 0, -1848, 5644, -25803, 3612, -29621, 3612, -29621, -2374, -17340, -1519, 7252, 0, 0, 0, 0,
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    ],
];

/// Mono samples (-1..1) as stereo PCM.
fn pcm(samples: &[f32]) -> Pcm {
    samples
        .iter()
        .flat_map(|&s| {
            let v = (s.clamp(-1.0, 1.0) * 24_000.0) as i16;
            [v, v]
        })
        .collect::<Vec<i16>>()
        .into()
}

/// Play `player` until it is quiet, or for `max` samples at most.
fn render(player: &mut GistPlayer, max: usize) -> Vec<f32> {
    let mut out = Vec::new();
    let mut buf = vec![0f32; RATE as usize / 50];
    while player.is_playing() && out.len() < max {
        player.generate_samples_into(&mut buf);
        out.extend_from_slice(&buf);
    }
    out
}

/// A GIST effect, played `times` over (one start every 200 ms, as IG
/// repeats its first five).
pub fn effect(words: &[i16; 56], times: u32) -> Pcm {
    let sound = GistSound::from(words);
    let mut mix: Vec<f32> = Vec::new();
    for n in 0..times.clamp(1, 50) as usize {
        let mut p = GistPlayer::with_sample_rate(RATE);
        p.play_sound(&sound, None, None);
        let one = render(&mut p, RATE as usize * MAX_SECONDS);
        let at = n * RATE as usize / 5;
        if mix.len() < at + one.len() {
            mix.resize(at + one.len(), 0.0);
        }
        for (i, s) in one.iter().enumerate() {
            mix[at + i] = (mix[at + i] + s).clamp(-1.0, 1.0);
        }
    }
    pcm(&mix)
}

/// Chip music (`n`): a GIST sound as a note of `pitch` (a semitone, 60
/// being middle C) at `volume` (0-15); held for `ticks` (200ths of a
/// second) and then released, or played out if None.
pub fn note(words: &[i16; 56], volume: u8, pitch: u8, ticks: Option<u32>) -> Pcm {
    let sound = GistSound::from(words);
    let mut p = GistPlayer::with_sample_rate(RATE);
    if p.play_sound_pitched(&sound, pitch as i16, Some(0), Some(volume.min(15) as i16), Some(1)).is_none() {
        return pcm(&[]);
    }
    // A note with no end of its own is released after two seconds.
    let held = ticks.map_or(2 * RATE as usize, |t| t as usize * RATE as usize / 200);
    let mut out = vec![0f32; held.min(RATE as usize * MAX_SECONDS)];
    p.generate_samples_into(&mut out);
    p.stop_voice(0);
    out.extend(render(&mut p, RATE as usize * 3));
    pcm(&out)
}

/// The ST's XBIOS `Dosound` data: register writes (`reg, value`), sweeps
/// (`0x80 v`, `0x81 reg, step, end`) and waits (`0x82 n`, in 50ths of a
/// second; 0 ends), driving the sound chip directly.
pub fn dosound(data: &[u8]) -> Pcm {
    let mut chip = Ym2149::with_clocks(2_000_000, RATE);
    let mut out: Vec<f32> = Vec::new();
    let tick = RATE as usize / 50;
    let play = |chip: &mut Ym2149, out: &mut Vec<f32>, ticks: usize| {
        for _ in 0..ticks * tick {
            chip.clock();
            out.push(chip.get_sample());
        }
    };
    let (mut i, mut temp) = (0, 0u8);
    while i < data.len() && out.len() < RATE as usize * MAX_SECONDS {
        match data[i] {
            r @ 0..=0x0F => {
                let Some(&v) = data.get(i + 1) else { break };
                chip.write_register(r, v);
                i += 2;
            }
            0x80 => {
                temp = data.get(i + 1).copied().unwrap_or(0);
                i += 2;
            }
            0x81 => {
                let (Some(&r), Some(&step), Some(&end)) = (data.get(i + 1), data.get(i + 2), data.get(i + 3)) else {
                    break;
                };
                // Each 50th of a second, the register steps until it
                // reaches the end value.
                for _ in 0..256 {
                    chip.write_register(r & 15, temp);
                    play(&mut chip, &mut out, 1);
                    if temp == end {
                        break;
                    }
                    temp = temp.wrapping_add(step);
                }
                i += 4;
            }
            0x82 => {
                let n = data.get(i + 1).copied().unwrap_or(0);
                if n == 0 {
                    break;
                }
                play(&mut chip, &mut out, n as usize);
                i += 2;
            }
            _ => i += 1,
        }
    }
    // What is sounding when the data ends rings on a little.
    play(&mut chip, &mut out, 25);
    pcm(&out)
}

/// The notes started in MIDI data: (note, velocity) of each Note On.
pub fn midi_notes(data: &[u8]) -> Vec<(u8, u8)> {
    let mut notes = Vec::new();
    let mut status = 0u8;
    let mut i = 0;
    while i < data.len() {
        let b = data[i];
        if b & 0x80 != 0 {
            status = b;
            i += 1;
            // System messages carry no running status.
            if b >= 0xF0 {
                status = 0;
            }
            continue;
        }
        let len = match status & 0xF0 {
            0x80 | 0x90 | 0xA0 | 0xB0 | 0xE0 => 2,
            0xC0 | 0xD0 => 1,
            _ => {
                i += 1;
                continue;
            }
        };
        if status & 0xF0 == 0x90 && i + 1 < data.len() && data[i + 1] > 0 {
            notes.push((data[i], data[i + 1]));
        }
        i += len;
    }
    notes
}

/// A MIDI note as a short plucked tone: BadTerm has no synthesizer for
/// the instrument it was meant for.
pub fn pluck(note: u8, velocity: u8) -> Pcm {
    let f = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
    let n = RATE as usize * 6 / 10;
    let level = velocity.min(127) as f32 / 127.0 * 0.35;
    let s: Vec<f32> = (0..n)
        .map(|i| {
            let t = i as f32 / RATE as f32;
            let phase = (t * f).fract();
            // A soft square, fading.
            let wave = if phase < 0.5 { 1.0 } else { -1.0 } * 0.6 + (t * f * std::f32::consts::TAU).sin() * 0.4;
            wave * level * (-t * 5.0).exp() * (1.0 - (-t * 400.0).exp())
        })
        .collect();
    pcm(&s)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn effects_notes_chip_and_midi() {
        let loud = |p: &Pcm| p.iter().map(|&s| (s as i32).abs()).max().unwrap_or(0);
        // Every effect makes a sound, and stops.
        for (i, words) in EFFECTS.iter().enumerate() {
            let p = effect(words, 1);
            assert!(loud(&p) > 1000, "{} is silent", NAMES[i]);
            assert!(p.len() / 2 <= RATE as usize * (MAX_SECONDS + 1), "{} never ends", NAMES[i]);
        }
        // Repeated, it's longer.
        assert!(effect(&EFFECTS[2], 5).len() > effect(&EFFECTS[2], 1).len());
        // A held note lasts about as asked, then fades.
        let n = note(&EFFECTS[8], 15, 60, Some(100));
        assert!(n.len() / 2 >= RATE as usize / 2);
        assert!(loud(&n) > 1000);
        // Channel A at 440 Hz, full volume, for half a second.
        let d = dosound(&[0, 0x1C, 1, 0x01, 7, 0x3E, 8, 15, 0x82, 25, 8, 0, 0x82, 0]);
        assert!(d.len() / 2 >= RATE as usize / 2 && loud(&d) > 1000);
        assert_eq!(midi_notes(&[0x90, 60, 100, 64, 90, 0x80, 60, 0, 0x90, 67, 0]), [(60, 100), (64, 90)]);
        assert!(loud(&pluck(60, 127)) > 1000);
    }
}