Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

features/audio.rs

//! Sound as data. Three ways a view asks for one, and none of them is a
//! call: a button declares a `click_sound` and the core plays it on the
//! click; a badge declares a `hover_sound` for the pointer's arrival; and a
//! loop is an `audio` node the view keeps declaring while it is on and
//! stops declaring to stop — the same way a toast exists while the model
//! holds it. The sounds are synthesized here (`kui_native::audio::blip`,
//! `wav_pcm16`) and registered once as resources, since a resource is
//! long-lived core state and not per-frame data.
//!
//! The dock's `audio` row is the driver's side of the same story: the
//! device opens off-thread the first time a session holds a sound, stays
//! open while anything is live, and is let go after a while idle — an open
//! output stream is a real-time thread whether or not anything plays, so
//! `open · 0 live` ten seconds after the last click would be a bug
//! (`env.audio`, ADR 0021).
//!
//! Run: cargo run -p kui-native --example audio [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::audio::{blip, wav_pcm16};
use kui_native::widgets;
use kui_native::{Align, App, AudioSpec, Core, NodeSpec, SoundId, TextStyle, Ui, UiEvent, Value};

#[derive(Default)]
struct Audio {
    clicks: u32,
    hovers: u32,
    hum: bool,
    /// Registered on the first frame (synthesized, so no asset files).
    sounds: Option<Sounds>,
}

#[derive(Clone, Copy)]
struct Sounds {
    click: SoundId,
    tick: SoundId,
    hum: SoundId,
}

/// A button with a click sound: the stock button spec plus one prop.
///
/// `name` is the node's key and `label` is what it says, and they are two
/// arguments because for one of these buttons they are two things: `hum`
/// reads `hum: off` and then `hum: on`. Keyed by its label it would be a
/// *different node* the frame after it is pressed — and everything the
/// core keeps per node is keyed too, so the focus would be left on a key
/// nothing declares any more and the ring would vanish under the press
/// that caused it. Hover and any tween would go the same way. The key is
/// what the button *is*; the label is a view of the state it toggles.
fn sound_button(ui: &mut Ui<'_>, name: &str, label: &str, payload: Value, sound: SoundId) {
    let spec = widgets::button_spec(&ui.theme(), &ui.metrics())
        .on_click(payload)
        .click_sound(sound);
    // Readable on whatever this button's background *is*, which is the
    // rule `widgets::button_with` applies to the stock one.
    let fg = widgets::readable_on(spec.style.bg);
    ui.text_in_keyed(
        name,
        spec,
        label,
        TextStyle::new(widgets::BUTTON_TEXT).color(fg),
    );
}

impl App for Audio {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        let sounds = *self.sounds.get_or_insert_with(|| {
            let core = ui.core();
            // A 200-sample period at 44.1kHz loops seamlessly.
            let hum: Vec<f32> = (0..2000)
                .map(|i| (i as f32 / 200.0 * std::f32::consts::TAU).sin() * 0.25)
                .collect();
            Sounds {
                click: core.add_sound(blip(44_100, 880.0, 60.0, 0.4)),
                tick: core.add_sound(blip(44_100, 1760.0, 25.0, 0.2)),
                hum: core.add_sound(wav_pcm16(44_100, &hum)),
            }
        });
        ui.with(NodeSpec::column().fill().center().gap(24.0), |ui| {
            // The loop: declared while on, and that is the whole of
            // "playing". Stop declaring it and the core stops it.
            if self.hum {
                ui.audio_keyed("hum", AudioSpec::new(sounds.hum).looped().volume(0.3));
            }
            ui.with(
                NodeSpec::column()
                    .pad(32.0)
                    .gap(20.0)
                    .bg(t.surface)
                    .radius(12.0)
                    .border(1.0, t.border)
                    .cross_align(Align::Center),
                |ui| {
                    ui.text("sound is data", TextStyle::new(14.0).color(t.muted));
                    ui.text(
                        &format!("{} clicks · {} hovers", self.clicks, self.hovers),
                        TextStyle::new(24.0),
                    );
                    ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
                        sound_button(
                            ui,
                            "blip",
                            "blip",
                            Value::map([("kind", "click".into())]),
                            sounds.click,
                        );
                        sound_button(
                            ui,
                            "hum",
                            if self.hum { "hum: on" } else { "hum: off" },
                            Value::map([("kind", "hum".into())]),
                            sounds.click,
                        );
                        // The hover sound: a `hoverable` badge with a
                        // `hover_sound` — the tick plays when the pointer
                        // arrives, and `on_hover` reports the same arrival
                        // as data so the count above can follow it.
                        ui.text_in_keyed(
                            "badge",
                            NodeSpec::column()
                                .pad_xy(12.0, 6.0)
                                .bg(t.raised)
                                .radius(10.0)
                                .hoverable()
                                .hover_sound(sounds.tick)
                                .on_hover(Value::map([("kind", "hover".into())])),
                            "hover me",
                            TextStyle::new(13.0),
                        );
                    });
                },
            );
            ui.text(
                "click_sound · hover_sound · an audio node declared while on",
                TextStyle::new(12.0).color(t.faint),
            );
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.kind() {
            Some("click") => self.clicks += 1,
            Some("hum") => self.hum = !self.hum,
            Some("hover") if ev.payload.get_str("phase") == Some("enter") => self.hovers += 1,
            _ => {}
        }
    }
}

impl Example for Audio {
    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(520.0, 360.0)
    }

    /// A headless core has no device, so what this checks is the data:
    /// the commands a click and a loop queue for a driver to apply.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 520.0, 360.0);
        d.frame(self);
        let blip = d.key_of("blip").ok_or("no blip button")?;
        let hum = d.key_of("hum").ok_or("no hum button")?;
        let _ = d.core.take_audio_commands();
        d.click_key(self, blip);
        let cmds = d.core.take_audio_commands();
        d.check(self.clicks == 1, "the click counts")?;
        d.check(!cmds.is_empty(), "and queues a play for the driver")?;

        d.click_key(self, hum);
        d.frame(self);
        let cmds = d.core.take_audio_commands();
        d.check(
            self.hum && !cmds.is_empty(),
            "declaring the loop queues its play",
        )?;
        d.click_key(self, hum);
        d.frame(self);
        let cmds = d.core.take_audio_commands();
        d.check(
            !self.hum && !cmds.is_empty(),
            "and undeclaring it queues the stop",
        )?;

        // The hover: the pointer arriving over the badge is `on_hover`'s
        // `enter`, and the tick is queued beside it.
        let badge = d.key_of("badge").ok_or("no badge")?;
        d.hover(self, badge);
        let cmds = d.core.take_audio_commands();
        d.check(
            self.hovers == 1,
            "the pointer's arrival is reported as data",
        )?;
        d.check(!cmds.is_empty(), "and the hover sound is queued")
    }
}

kui_devtools::main!(Audio::default());