midi-harbor/crates/daemon/tests/silencing.rs
2026-09-28 13:59:10 -05:00

527 lines
19 KiB
Rust

//! Notes are stopped before a port stops being able to stop them.
//!
//! A note on with no matching note off is the failure a musician hears, and the one they cannot
//! fix from the application that caused it: the note is sounding on a synth nothing is talking to
//! any more. Deleting or disabling a port is exactly the moment that happens.
#![allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)]
mod common;
use midi_harbor_core::endpoint::Direction;
use midi_harbor_core::fingerprint::DeviceFingerprint;
use midi_harbor_core::midi::{Channel, MidiMessage, silence_channel};
use midi_harbor_core::paths::Paths;
use midi_harbor_daemon::Daemon;
use midi_harbor_platform::fake::FakeMidiPlatform;
use midi_harbor_platform::midi::DiscoveredDevice;
use midi_harbor_platform::midi::{MidiPlatform, PortHandle};
use std::sync::Arc;
use std::time::Duration;
/// Starts a daemon over a fake platform in its own scratch directory.
async fn started(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
let root =
common::scratch("midi-harbor-silencing").join(format!("{label}-{}", uuid::Uuid::new_v4()));
let platform = Arc::new(FakeMidiPlatform::new());
let daemon = Daemon::start(
Paths::rooted_at(root),
Arc::clone(&platform) as Arc<dyn MidiPlatform>,
)
.await
.expect("the daemon starts over a scratch directory");
(daemon, platform)
}
/// Builds a daemon over a fake platform with one route from Keyboard to Synth.
async fn routed(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
let (daemon, platform) = started(label).await;
for name in ["Keyboard", "Synth"] {
daemon
.create_virtual_port(name, 1, 1)
.await
.expect("the virtual port is created");
}
daemon
.create_route("Keyboard", "Synth")
.await
.expect("the route from Keyboard to Synth is created");
(daemon, platform)
}
/// Returns the platform handle of a named port.
fn handle_for(platform: &FakeMidiPlatform, name: &str) -> PortHandle {
platform
.port_handle(name)
.unwrap_or_else(|| panic!("no platform handle for {name}"))
}
/// Returns a channel by its zero-based index.
fn channel(index: u8) -> Channel {
Channel::new(index).expect("the channel index is in range")
}
/// Returns a note on at a fixed velocity.
fn note_on(index: u8, note: u8) -> MidiMessage {
MidiMessage::NoteOn {
channel: channel(index),
note,
velocity: 100,
}
}
/// Returns a note off with zero release velocity.
fn note_off(index: u8, note: u8) -> MidiMessage {
MidiMessage::NoteOff {
channel: channel(index),
note,
velocity: 0,
}
}
/// Returns what silencing one held note on a channel nobody else plays sends: the pedal release,
/// the note's own note off, then all-notes-off and all-sound-off, four messages in all.
fn silenced(index: u8, note: u8) -> Vec<MidiMessage> {
let [pedal, notes_off, sound_off] = silence_channel(channel(index));
vec![pedal, note_off(index, note), notes_off, sound_off]
}
/// Waits for a condition, so the test does not depend on dispatch timing.
async fn eventually(mut check: impl FnMut() -> bool) -> bool {
for _ in 0..200 {
if check() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
false
}
/// Plays a message from Keyboard and waits for it to arrive at Synth.
async fn play(platform: &FakeMidiPlatform, message: MidiMessage) {
let synth = handle_for(platform, "Synth");
let before = platform.sent(synth).len();
assert!(
platform.feed(handle_for(platform, "Keyboard"), &[message]),
"the keyboard port accepts the message fed into it"
);
assert!(
eventually(|| platform.sent(synth).len() > before).await,
"the note never reached the destination"
);
}
/// What a test takes away from the Keyboard to Synth route.
#[derive(Clone, Copy, Debug)]
enum Withdraw {
/// Switches off the named port.
DisablePort(&'static str),
/// Deletes the named port.
DeletePort(&'static str),
/// Switches off the only route.
DisableRoute,
/// Deletes the only route.
DeleteRoute,
/// Silences everything, as the daemon does when it stops.
StopDaemon,
}
/// Applies one withdrawal to a daemon built by `routed`.
async fn withdraw(daemon: &Arc<Daemon>, action: Withdraw) {
match action {
Withdraw::DisablePort(name) => {
let id = daemon.resolve(name).await.expect("the port resolves");
daemon
.set_enabled(id, false)
.await
.expect("the port is switched off");
}
Withdraw::DeletePort(name) => {
let id = daemon.resolve(name).await.expect("the port resolves");
daemon
.delete_virtual_port(id)
.await
.expect("the port is deleted");
}
Withdraw::DisableRoute => {
let route = daemon
.router()
.await
.routes()
.first()
.expect("the daemon has the one route")
.id;
daemon
.set_route_enabled(&route.to_string(), false)
.await
.expect("the route is switched off");
}
Withdraw::DeleteRoute => {
let route = daemon
.router()
.await
.routes()
.first()
.expect("the daemon has the one route")
.id;
daemon
.delete_route(&route.to_string())
.await
.expect("the route is deleted");
}
Withdraw::StopDaemon => daemon.silence_all().await,
}
}
/// Proves that a note left sounding at a destination is stopped whenever anything carrying it
/// goes away: the destination, the source, the route between them, or the daemon itself.
///
/// Each row plays one note from Keyboard to Synth and withdraws one thing. Synth then receives
/// the four messages of `silenced`, and only for the channel the note was on. A reset on a channel
/// nothing played would still clear sustain and cut sound for whatever else drives the port.
#[tokio::test]
async fn a_note_left_sounding_is_stopped_whenever_what_carries_it_goes_away() {
struct Case {
name: &'static str,
action: Withdraw,
channel: u8,
note: u8,
why: &'static str,
}
let cases = [
Case {
name: "switching off the destination",
action: Withdraw::DisablePort("Synth"),
channel: 0,
note: 60,
why: "the port is closed afterwards, so nothing can release the note later",
},
Case {
name: "deleting the destination",
action: Withdraw::DeletePort("Synth"),
channel: 0,
note: 60,
why: "the port is gone afterwards, so nothing can release the note later",
},
Case {
name: "switching off the source",
action: Withdraw::DisablePort("Keyboard"),
channel: 0,
note: 60,
why: "the note off would come from the source, whose routes are now suspended",
},
Case {
name: "deleting the source",
action: Withdraw::DeletePort("Keyboard"),
channel: 0,
note: 60,
why: "the note off would come from the source, which no longer exists",
},
Case {
name: "switching off the route",
action: Withdraw::DisableRoute,
channel: 0,
note: 60,
why: "the note off would travel the route, which no longer carries anything",
},
Case {
name: "stopping the daemon",
action: Withdraw::StopDaemon,
channel: 0,
note: 60,
why: "the only thing that knew the note was playing is this process",
},
Case {
name: "switching off the destination after a drum note on channel ten",
action: Withdraw::DisablePort("Synth"),
channel: 9,
note: 38,
why: "only the channel that was played is reset, never all sixteen",
},
];
for case in cases {
let (daemon, platform) = routed(&case.name.replace(' ', "-")).await;
let synth = handle_for(&platform, "Synth");
play(&platform, note_on(case.channel, case.note)).await;
let before = platform.sent(synth).len();
withdraw(&daemon, case.action).await;
let want = silenced(case.channel, case.note);
eventually(|| platform.sent(synth).len() >= before + want.len()).await;
assert_eq!(
platform.sent(synth).split_off(before),
want,
"{}: the held note must be stopped because {}",
case.name,
case.why
);
}
}
/// Proves that silence is earned rather than sent on principle: withdrawing a port or route with
/// no note left sounding sends nothing at all to the destination.
///
/// Resetting a port nobody was playing clears sustain and cuts sound for whatever else is
/// connected to it.
#[tokio::test]
async fn nothing_is_sent_where_nothing_is_left_sounding() {
struct Case {
name: &'static str,
played: Vec<MidiMessage>,
action: Withdraw,
}
let cases = [
Case {
name: "an idle port switched off",
played: vec![],
action: Withdraw::DisablePort("Synth"),
},
Case {
name: "a port whose only note was released, switched off",
played: vec![note_on(0, 60), note_off(0, 60)],
action: Withdraw::DisablePort("Synth"),
},
Case {
name: "an idle route deleted",
played: vec![],
action: Withdraw::DeleteRoute,
},
];
for case in cases {
let (daemon, platform) = routed(&case.name.replace(' ', "-")).await;
let synth = handle_for(&platform, "Synth");
for message in &case.played {
play(&platform, *message).await;
}
let before = platform.sent(synth);
withdraw(&daemon, case.action).await;
assert_eq!(
platform.sent(synth),
before,
"{}: the destination was disturbed although nothing was sounding",
case.name
);
}
}
/// Proves that silencing leaves a `NotesSilenced` event naming the port, because a synth going
/// quiet on its own is alarming unless something says why.
#[tokio::test]
async fn silencing_is_recorded_so_it_can_be_explained_afterwards() {
let (daemon, platform) = routed("recorded").await;
play(&platform, note_on(0, 60)).await;
let id = daemon.resolve("Synth").await.expect("the synth resolves");
daemon
.set_enabled(id, false)
.await
.expect("the synth is switched off");
let events = daemon.events(None, 100).await;
let silenced = events
.iter()
.find(|event| event.kind == midi_harbor_core::events::EventKind::NotesSilenced)
.expect("the silencing was not recorded");
assert_eq!(
silenced.endpoint,
Some(id),
"the event names the port that was silenced"
);
assert!(
silenced.detail.contains("Synth"),
"the event detail names the port by name, got {}",
silenced.detail
);
}
/// Proves that a port disabled and enabled again starts with nothing recorded as sounding.
///
/// The record has to be cleared with the silence, or the next disable resets channels that
/// nothing has played since.
#[tokio::test]
async fn a_port_disabled_and_enabled_again_starts_quiet() {
let (daemon, platform) = routed("reopened").await;
play(&platform, note_on(0, 60)).await;
let id = daemon.resolve("Synth").await.expect("the synth resolves");
daemon
.set_enabled(id, false)
.await
.expect("the synth is switched off");
daemon
.set_enabled(id, true)
.await
.expect("the synth is switched on again");
// The port was rebuilt, so this is the handle of the reopened one.
let reopened = handle_for(&platform, "Synth");
daemon
.set_enabled(id, false)
.await
.expect("the synth is switched off again");
assert!(
platform.sent(reopened).is_empty(),
"a reopened port was reset for notes played before it was closed"
);
}
/// Builds a daemon with a Keystation keyboard attached and the virtual ports the unplugging
/// cases route between: Pads as a second source, Synth and Sampler as destinations, and Rack with
/// two MIDI Outs.
async fn with_hardware(label: &str) -> (Arc<Daemon>, Arc<FakeMidiPlatform>) {
let (daemon, platform) = started(label).await;
platform.attach(DiscoveredDevice {
fingerprint: DeviceFingerprint {
name: "Keystation".to_owned(),
..DeviceFingerprint::default()
},
direction: Direction::Bidirectional,
claimed_by: None,
software: false,
});
daemon.refresh_devices().await;
for (name, outs) in [("Synth", 1), ("Pads", 1), ("Sampler", 1), ("Rack", 2)] {
daemon
.create_virtual_port(name, 1, outs)
.await
.expect("the virtual port is created");
}
(daemon, platform)
}
/// Plays a note from the Keystation and waits for it to leave through one MIDI Out of a port.
async fn play_from_hardware(platform: &FakeMidiPlatform, to: &str, out: u8, message: MidiMessage) {
// Fed until it lands rather than once: the daemon re-enumerates hardware on its own
// schedule, so the handle the fake reports can be a moment behind the one it has just opened.
let destination = handle_for(platform, to);
let before = platform.sent_through(destination, out).len();
for _ in 0..40 {
if let Some(source) = platform.device_handle("Keystation") {
let _ = platform.feed(source, &[message]);
}
if platform.sent_through(destination, out).len() > before {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("the note never reached {to}");
}
/// Proves FR-015f: unplugging a keyboard stops the notes it was holding, and only those.
///
/// Nobody can release them otherwise: the keyboard that would send the note off is in a bag.
/// Each row routes the Keystation to one MIDI Out and, in most rows, a second source (Pads) to
/// the same or another place. A destination can be fed by several sources, and cutting off the
/// others because one went away would turn one silent instrument into all of them. Only notes
/// leaving through the same MIDI Out share a channel with the unplugged source's route.
#[tokio::test]
async fn unplugging_a_keyboard_stops_its_own_notes_and_nobody_elses() {
/// Where a source's note goes: its channel, its note, the port and the MIDI Out.
type Play = (u8, u8, &'static str, u8);
struct Case {
name: &'static str,
keystation: Play,
pads: Option<Play>,
want: Vec<MidiMessage>,
why: &'static str,
}
let cases = [
Case {
name: "the only source",
keystation: (0, 60, "Synth", 0),
pads: None,
want: silenced(0, 60),
why: "the channel is the keyboard's alone, so it gets the full reset",
},
Case {
name: "another source on another channel into the same destination",
keystation: (0, 60, "Synth", 0),
pads: Some((5, 72, "Synth", 0)),
want: silenced(0, 60),
why: "channel six keeps its note, so only channel one is reset",
},
Case {
name: "another source on the same channel into the same destination",
keystation: (9, 36, "Synth", 0),
pads: Some((9, 38, "Synth", 0)),
want: vec![note_off(9, 36)],
why: "the pedal release and broad resets on a shared channel stopped the other \
controller's held note along with the unplugged one's",
},
Case {
name: "another source on the same channel into another destination",
keystation: (9, 36, "Synth", 0),
pads: Some((9, 38, "Sampler", 0)),
want: silenced(9, 36),
why: "a note sounding at another destination does not hold back the reset",
},
Case {
name: "another source on the same channel through another MIDI Out",
keystation: (9, 36, "Rack", 0),
pads: Some((9, 38, "Rack", 1)),
want: silenced(9, 36),
why: "each MIDI Out is its own cable to its own instrument, so a note through one \
does not share a channel with a route through another",
},
];
for case in cases {
let (daemon, platform) = with_hardware(&case.name.replace(' ', "-")).await;
let (channel, note, to, out) = case.keystation;
daemon
.create_route_through("Keystation", 0, to, out)
.await
.expect("the route from the Keystation is created");
if let Some((_, _, pads_to, pads_out)) = case.pads {
daemon
.create_route_through("Pads", 0, pads_to, pads_out)
.await
.expect("the route from Pads is created");
}
// Play the Keystation's note, then the other source's.
play_from_hardware(&platform, to, out, note_on(channel, note)).await;
if let Some((pads_channel, pads_note, pads_to, pads_out)) = case.pads {
let destination = handle_for(&platform, pads_to);
let before = platform.sent_through(destination, pads_out).len();
assert!(
platform.feed(
handle_for(&platform, "Pads"),
&[note_on(pads_channel, pads_note)]
),
"{}: the pads port accepts the note fed into it",
case.name
);
assert!(
eventually(|| platform.sent_through(destination, pads_out).len() > before).await,
"{}: the other source's note never reached its destination",
case.name
);
}
// Unplug the Keystation and read what its destination was sent since.
let destination = handle_for(&platform, to);
let before = platform.sent_through(destination, out).len();
platform.detach("Keystation");
daemon.refresh_devices().await;
assert_eq!(
platform.sent_through(destination, out).split_off(before),
case.want,
"{}: unplugging the keyboard must stop exactly its own notes because {}",
case.name,
case.why
);
}
}