556 lines
22 KiB
Rust
556 lines
22 KiB
Rust
//! A score playing live: the composer (the Splash VM never leaves it) runs
|
|
//! each loop's code ahead of the clock and sends the events into the loops'
|
|
//! feeds, which the `Loop` nodes of the audio graph read on the audio
|
|
//! thread. Where the platform has threads it runs on its own; where it has
|
|
//! none (the web), the host calls [`LiveScore::pump`] on its own schedule.
|
|
//! Nothing in it reads a clock but the host's [`ClockSource`]. The graph itself (the lowered score, part of
|
|
//! whatever audio graph instantiates it) is the host's: Stage's audio root,
|
|
//! a game's engine, an offline render.
|
|
//!
|
|
//! A new source is loaded here; if it loads, loops change at their next
|
|
//! cycle; if it does not, the last good revision keeps playing and the
|
|
//! status carries the problems with their lines.
|
|
|
|
use crate::compose::{Composer, Played};
|
|
use crate::model::{self, Diag};
|
|
use makepad_audio_engine::feed::{loop_feed, LoopFeed};
|
|
use std::collections::HashMap;
|
|
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::JoinHandle;
|
|
use std::time::Duration;
|
|
|
|
/// Where the music is: the beat playing at the output, the tempo and
|
|
/// whether it plays (Stage's transport, a game's clock).
|
|
pub trait ClockSource: Send + Sync {
|
|
fn now(&self) -> Option<(f64, f64, bool)>;
|
|
}
|
|
|
|
pub enum Cmd {
|
|
Source(String),
|
|
Param(String, f32),
|
|
Trigger(String),
|
|
Goto { section: String, quant: f64 },
|
|
/// Change the tempo from the next beat not yet scheduled, over `ramp`
|
|
/// beats.
|
|
Tempo { bpm: f64, ramp: f64 },
|
|
/// Send the tempo changes to another transport from now on.
|
|
Transport(String),
|
|
/// A message from the MIDI input (see [`Composer::midi_in`]).
|
|
Midi([u8; 3]),
|
|
/// Play the arrangement from `beat` (song beats) at the next `quant`.
|
|
Seek { beat: f64, quant: f64 },
|
|
/// Repeat song beats [from, to), or play on.
|
|
Loop(Option<(f64, f64)>),
|
|
Quit,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct LoopView {
|
|
pub name: String,
|
|
pub key: u64,
|
|
pub cycle: u64,
|
|
/// The cycle playing or last generated: its start and length in beats.
|
|
pub start: f64,
|
|
pub len: f64,
|
|
/// What it plays, as (offset beat, note or drum slot, velocity, hit).
|
|
pub notes: Vec<(f64, f32, f32, bool)>,
|
|
/// Each note's length in beats (0: the patch's own gate).
|
|
pub durs: Vec<f32>,
|
|
/// What it sang: (offset beat, syllable).
|
|
pub lyrics: Vec<(f64, String)>,
|
|
/// What its code logged (`log(x)`) in its last cycle.
|
|
pub logs: Vec<String>,
|
|
pub resting: bool,
|
|
pub error: Option<Diag>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Status {
|
|
/// Sources received, and the one playing now.
|
|
pub revision: u64,
|
|
pub playing_revision: u64,
|
|
/// Problems of the newest source (empty when it plays).
|
|
pub diags: Vec<Diag>,
|
|
pub label: String,
|
|
pub bpm: f64,
|
|
pub sig: (u32, u32),
|
|
pub beat: f64,
|
|
pub playing: bool,
|
|
pub section: Option<String>,
|
|
pub loops: Vec<LoopView>,
|
|
pub recent: Vec<Played>,
|
|
pub params: Vec<(String, f32)>,
|
|
pub cues: Vec<(String, f64)>,
|
|
/// Events the composer could not hand over (a full feed).
|
|
pub dropped: u32,
|
|
/// Where the output is in the arrangement, in beats from its start
|
|
/// (None off it), and the loop repeating.
|
|
pub song_beat: Option<f64>,
|
|
pub looping: Option<(f64, f64)>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct LiveConfig {
|
|
/// The score's node in the graph: its loops' feeds are `<instance>/<loop>`.
|
|
pub instance: String,
|
|
/// How far ahead of the output the composer schedules, in seconds.
|
|
pub lookahead: f64,
|
|
pub clock: Arc<dyn ClockSource>,
|
|
}
|
|
|
|
pub struct LiveScore {
|
|
cmd: Sender<Cmd>,
|
|
pub status: Arc<Mutex<Status>>,
|
|
thread: Option<JoinHandle<()>>,
|
|
/// The composer when it has no thread: the host pumps it.
|
|
inline: Option<Mutex<Worker>>,
|
|
instance: String,
|
|
}
|
|
|
|
/// Whether this platform can start threads (it cannot on the web without
|
|
/// shared memory): asked once, by starting one.
|
|
pub fn threads_available() -> bool {
|
|
static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
|
*AVAILABLE.get_or_init(|| std::thread::Builder::new().spawn(|| {}).map(|t| t.join().is_ok()).unwrap_or(false))
|
|
}
|
|
|
|
/// The composer thread's step: about every 5 ms, its status every 25 ms.
|
|
const STEP: Duration = Duration::from_millis(5);
|
|
const PUBLISH_EVERY: u32 = 5;
|
|
|
|
impl LiveScore {
|
|
/// A live score whose tempo changes go to the transport of a
|
|
/// standalone root (`<instance>_clock`: games).
|
|
pub fn new(config: LiveConfig) -> LiveScore {
|
|
let transport = crate::player::transport(&config.instance);
|
|
LiveScore::new_on(config, &transport)
|
|
}
|
|
|
|
/// A live score whose tempo changes (sections' `bpm`, a tempo that
|
|
/// follows a param, `set_tempo`) go to the transport `transport`.
|
|
pub fn new_on(config: LiveConfig, transport: &str) -> LiveScore {
|
|
LiveScore::start(config, transport, false)
|
|
}
|
|
|
|
/// One the host pumps ([`LiveScore::pump`]) even where threads exist.
|
|
pub fn new_pumped(config: LiveConfig, transport: &str) -> LiveScore {
|
|
LiveScore::start(config, transport, true)
|
|
}
|
|
|
|
fn start(config: LiveConfig, transport: &str, pumped: bool) -> LiveScore {
|
|
// Its MIDI loops play to the MIDI outputs.
|
|
makepad_audio_engine::feed::midi_out::set_live(&config.instance, true);
|
|
let instance = config.instance.clone();
|
|
let tempo = makepad_audio_engine::feed::tempo_feed(transport);
|
|
let status = Arc::new(Mutex::new(Status { bpm: 120.0, sig: (4, 4), ..Status::default() }));
|
|
let (tx, rx) = channel();
|
|
// Made where it runs: the composer's VM never moves between threads.
|
|
let worker = {
|
|
let status = status.clone();
|
|
move || Worker { config, rx, status, composer: None, source: String::new(), revision: 0, feeds: HashMap::new(), last_beat: None, dropped: 0, tempo, osc_in: None, osc_out: None, osc_pending: Vec::new(), seek_stopped: None, loop_range: None, steps: 0 }
|
|
};
|
|
if !threads_available() || pumped {
|
|
return LiveScore { cmd: tx, status, thread: None, inline: Some(Mutex::new(worker())), instance };
|
|
}
|
|
let thread = std::thread::Builder::new().name("score-composer".into()).spawn(move || worker().run()).expect("the composer thread");
|
|
LiveScore { cmd: tx, status, thread: Some(thread), inline: None, instance }
|
|
}
|
|
|
|
/// Without a composer thread: run what is due (commands, the loops'
|
|
/// code up to the lookahead, the status). The host calls it often
|
|
/// (every audio block or frame, at least every lookahead / 2); with a
|
|
/// thread it does nothing.
|
|
pub fn pump(&self) {
|
|
if let Some(worker) = &self.inline {
|
|
if let Ok(mut worker) = worker.lock() {
|
|
worker.step(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn send(&self, cmd: Cmd) {
|
|
let _ = self.cmd.send(cmd);
|
|
}
|
|
|
|
pub fn set_source(&self, source: impl Into<String>) {
|
|
self.send(Cmd::Source(source.into()));
|
|
}
|
|
|
|
pub fn set_param(&self, name: &str, value: f32) {
|
|
self.send(Cmd::Param(name.into(), value));
|
|
}
|
|
|
|
pub fn trigger(&self, stinger: &str) {
|
|
self.send(Cmd::Trigger(stinger.into()));
|
|
}
|
|
|
|
/// Jump to a section on the next bar (`quant < 0`), beat (`1`) or now (`0`).
|
|
pub fn goto_section(&self, section: &str, quant: f64) {
|
|
self.send(Cmd::Goto { section: section.into(), quant });
|
|
}
|
|
|
|
/// Send the tempo changes to `transport` from now on (Stage: its own
|
|
/// transport while BPM follows the score, else a transport nobody
|
|
/// reads).
|
|
pub fn set_transport(&self, transport: &str) {
|
|
self.send(Cmd::Transport(transport.to_string()));
|
|
}
|
|
|
|
/// Change the tempo from the next beat, reached over `ramp` beats.
|
|
/// From the next `quant` after now (0: now, <0: the next bar), play
|
|
/// the arrangement from `beat` beats from its start (whole bars): the
|
|
/// section there from its offset. While stopped, the next play starts
|
|
/// there.
|
|
pub fn seek(&self, beat: f64, quant: f64) {
|
|
self.send(Cmd::Seek { beat, quant });
|
|
}
|
|
|
|
/// Repeat the arrangement's beats [from, to) (whole bars), jumping on
|
|
/// the bar grid; None plays on. A seek outside keeps it: the loop takes
|
|
/// the playhead when it enters.
|
|
pub fn set_loop(&self, range: Option<(f64, f64)>) {
|
|
self.send(Cmd::Loop(range));
|
|
}
|
|
|
|
pub fn set_tempo(&self, bpm: f64, ramp: f64) {
|
|
self.send(Cmd::Tempo { bpm, ramp });
|
|
}
|
|
|
|
pub fn status(&self) -> Status {
|
|
self.status.lock().map(|s| s.clone()).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl Drop for LiveScore {
|
|
fn drop(&mut self) {
|
|
let _ = self.cmd.send(Cmd::Quit);
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
makepad_audio_engine::feed::midi_out::set_live(&self.instance, false);
|
|
}
|
|
}
|
|
|
|
struct Worker {
|
|
config: LiveConfig,
|
|
rx: Receiver<Cmd>,
|
|
status: Arc<Mutex<Status>>,
|
|
composer: Option<Composer>,
|
|
source: String,
|
|
revision: u64,
|
|
feeds: HashMap<u64, Arc<LoopFeed>>,
|
|
last_beat: Option<f64>,
|
|
dropped: u32,
|
|
tempo: Arc<makepad_audio_engine::feed::TempoFeed>,
|
|
/// Steps taken (the status goes out every few).
|
|
steps: u32,
|
|
/// A seek made while stopped (the next play starts there) and the loop
|
|
/// (kept across restarts).
|
|
seek_stopped: Option<f64>,
|
|
loop_range: Option<(f64, f64)>,
|
|
/// OSC: the socket listening on the Clock's `osc_in` port, the one
|
|
/// sending, and what waits for its beat.
|
|
osc_in: Option<(u16, std::net::UdpSocket)>,
|
|
osc_out: Option<std::net::UdpSocket>,
|
|
osc_pending: Vec<(f64, String, Vec<u8>)>,
|
|
}
|
|
|
|
impl Worker {
|
|
/// The composer thread: a step whenever a command comes or every
|
|
/// [`STEP`].
|
|
fn run(mut self) {
|
|
loop {
|
|
match self.rx.recv_timeout(STEP) {
|
|
Ok(Cmd::Quit) | Err(RecvTimeoutError::Disconnected) => break,
|
|
Ok(cmd) => self.command(cmd),
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
}
|
|
if !self.step(PUBLISH_EVERY) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The commands waiting, the loops' code up to the lookahead, OSC, and
|
|
/// the status every `publish_every` steps. False once told to quit.
|
|
fn step(&mut self, publish_every: u32) -> bool {
|
|
while let Ok(cmd) = self.rx.try_recv() {
|
|
if let Cmd::Quit = cmd {
|
|
return false;
|
|
}
|
|
self.command(cmd);
|
|
}
|
|
self.pump();
|
|
self.osc();
|
|
self.steps = self.steps.wrapping_add(1);
|
|
if self.steps % publish_every.max(1) == 0 {
|
|
self.publish();
|
|
}
|
|
true
|
|
}
|
|
|
|
/// OSC in (the Clock's `osc_in` port) into the composer, and what loop
|
|
/// code sent out when its beat comes.
|
|
fn osc(&mut self) {
|
|
if !crate::osc::available() {
|
|
return;
|
|
}
|
|
let Some(composer) = &mut self.composer else { return };
|
|
let wanted = composer.doc.clock.osc_in;
|
|
if self.osc_in.as_ref().map(|(p, _)| *p) != wanted {
|
|
self.osc_in = wanted.and_then(|port| match std::net::UdpSocket::bind(("0.0.0.0", port)) {
|
|
Ok(socket) if socket.set_nonblocking(true).is_ok() => Some((port, socket)),
|
|
Ok(_) => None,
|
|
Err(error) => {
|
|
eprintln!("score OSC: cannot listen on port {port}: {error}");
|
|
None
|
|
}
|
|
});
|
|
}
|
|
let clock = self.config.clock.now();
|
|
let beat = clock.map_or(0.0, |(beat, ..)| beat);
|
|
if let Some((_, socket)) = &self.osc_in {
|
|
let mut buf = [0u8; 2048];
|
|
for _ in 0..256 {
|
|
let Ok((n, _)) = socket.recv_from(&mut buf) else { break };
|
|
for (addr, args) in crate::osc::decode(&buf[..n]) {
|
|
composer.osc_in(&addr, args, beat);
|
|
}
|
|
}
|
|
for (fx, event) in composer.automation_out.drain(..) {
|
|
makepad_audio_engine::feed::automation_feed(&format!("{}/{fx}", self.config.instance)).send(event);
|
|
}
|
|
}
|
|
self.osc_pending.append(&mut composer.osc_out);
|
|
let playing = clock.is_some_and(|(.., playing)| playing);
|
|
if !playing {
|
|
self.osc_pending.clear();
|
|
return;
|
|
}
|
|
if self.osc_pending.iter().any(|(at, ..)| *at <= beat) && self.osc_out.is_none() {
|
|
self.osc_out = std::net::UdpSocket::bind(("0.0.0.0", 0)).ok();
|
|
}
|
|
let Some(socket) = &self.osc_out else { return };
|
|
self.osc_pending.retain(|(at, target, packet)| {
|
|
if *at > beat {
|
|
return true;
|
|
}
|
|
let _ = socket.send_to(packet, target.as_str());
|
|
false
|
|
});
|
|
}
|
|
|
|
fn feeds_for(&mut self) {
|
|
let Some(composer) = &self.composer else { return };
|
|
for name in composer.doc.feed_names() {
|
|
let key = crate::key(&name);
|
|
self.feeds.entry(key).or_insert_with(|| loop_feed(&format!("{}/{name}", self.config.instance)));
|
|
}
|
|
}
|
|
|
|
/// Start every loop over from the bar holding `beat` (the clock jumped).
|
|
fn restart_at(&mut self, beat: f64) {
|
|
let Ok(loaded) = model::load(&self.source) else { return };
|
|
let params = self.composer.as_ref().map(|c| c.params.clone()).unwrap_or_default();
|
|
let mut fresh = Composer::new(loaded);
|
|
for (name, value) in params {
|
|
fresh.set_param(&name, value);
|
|
}
|
|
let bar = fresh.doc.beats_per_bar();
|
|
let at = (beat / bar).ceil() * bar;
|
|
for state in &mut fresh.loops {
|
|
state.next = at;
|
|
}
|
|
fresh.set_loop(self.loop_range);
|
|
if let Some(song) = self.seek_stopped.take() {
|
|
fresh.seek(song, at, 0.0);
|
|
}
|
|
self.composer = Some(fresh);
|
|
}
|
|
|
|
fn pump(&mut self) {
|
|
let Some((now, bpm, playing)) = self.config.clock.now() else { return };
|
|
if self.composer.is_none() || !playing {
|
|
self.last_beat = None;
|
|
return;
|
|
}
|
|
// The clock started, or jumped (a locate): begin on the next bar.
|
|
match self.last_beat {
|
|
None => self.restart_at(now),
|
|
Some(last) if now < last - 0.5 || now > last + 16.0 => self.restart_at(now),
|
|
_ => {}
|
|
}
|
|
self.last_beat = Some(now);
|
|
let until = now + self.config.lookahead * bpm / 60.0;
|
|
let mut events = Vec::new();
|
|
if let Some(composer) = &mut self.composer {
|
|
composer.pump(until, &mut events);
|
|
for change in composer.tempo_out.drain(..) {
|
|
self.tempo.send(change);
|
|
}
|
|
for (fx, event) in composer.automation_out.drain(..) {
|
|
makepad_audio_engine::feed::automation_feed(&format!("{}/{fx}", self.config.instance)).send(event);
|
|
}
|
|
}
|
|
for event in events {
|
|
match self.feeds.get(&event.loop_key) {
|
|
Some(feed) if feed.send(event) => {}
|
|
_ => self.dropped += 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn command(&mut self, cmd: Cmd) {
|
|
let now = self.config.clock.now().map_or(0.0, |(beat, bpm, _)| beat + self.config.lookahead * bpm / 60.0);
|
|
match cmd {
|
|
Cmd::Source(source) => self.load(source, now),
|
|
Cmd::Param(name, value) => {
|
|
if let Some(composer) = &mut self.composer {
|
|
composer.set_param(&name, value);
|
|
}
|
|
}
|
|
Cmd::Trigger(name) => {
|
|
if let Some(composer) = &mut self.composer {
|
|
let _ = composer.trigger(&name, now);
|
|
}
|
|
}
|
|
Cmd::Goto { section, quant } => {
|
|
if let Some(composer) = &mut self.composer {
|
|
let _ = composer.goto_section(§ion, now, quant);
|
|
}
|
|
}
|
|
Cmd::Tempo { bpm, ramp } => {
|
|
if let Some(composer) = &mut self.composer {
|
|
composer.set_tempo(bpm, ramp);
|
|
}
|
|
}
|
|
Cmd::Transport(name) => {
|
|
self.tempo = makepad_audio_engine::feed::tempo_feed(&name);
|
|
// The new transport starts from the tempo map as it stands.
|
|
if let Some(composer) = &self.composer {
|
|
let beat = now;
|
|
let mut current = None;
|
|
for change in composer.tempo_map.iter().filter(|c| c.beat <= beat) {
|
|
current = Some(*change);
|
|
}
|
|
if let Some(change) = current {
|
|
self.tempo.send(makepad_audio_engine::feed::TempoEvent { beat, bpm: change.bpm, ramp: 0.0 });
|
|
}
|
|
}
|
|
}
|
|
Cmd::Seek { beat, quant } => {
|
|
let playing = self.config.clock.now().is_some_and(|(.., playing)| playing);
|
|
match &mut self.composer {
|
|
Some(composer) if playing && self.last_beat.is_some() => {
|
|
composer.seek(beat, now, quant);
|
|
}
|
|
_ => self.seek_stopped = Some(beat),
|
|
}
|
|
}
|
|
Cmd::Loop(range) => {
|
|
self.loop_range = range;
|
|
if let Some(composer) = &mut self.composer {
|
|
composer.set_loop(range);
|
|
}
|
|
}
|
|
Cmd::Midi(data) => {
|
|
// Played now: stamped with the beat playing, not ahead.
|
|
let beat = self.config.clock.now().map_or(0.0, |(beat, ..)| beat);
|
|
let mut events = Vec::new();
|
|
if let Some(composer) = &mut self.composer {
|
|
composer.midi_in(data, beat, &mut events);
|
|
for (fx, event) in composer.automation_out.drain(..) {
|
|
makepad_audio_engine::feed::automation_feed(&format!("{}/{fx}", self.config.instance)).send(event);
|
|
}
|
|
}
|
|
for event in events {
|
|
match self.feeds.get(&event.loop_key) {
|
|
Some(feed) if feed.send(event) => {}
|
|
_ => self.dropped += 1,
|
|
}
|
|
}
|
|
}
|
|
Cmd::Quit => {}
|
|
}
|
|
}
|
|
|
|
fn load(&mut self, source: String, now: f64) {
|
|
self.revision += 1;
|
|
match model::load(&source) {
|
|
Err(diags) => {
|
|
if let Ok(mut status) = self.status.lock() {
|
|
status.revision = self.revision;
|
|
status.diags = diags;
|
|
}
|
|
}
|
|
Ok(loaded) => {
|
|
match &mut self.composer {
|
|
Some(composer) => composer.reload(loaded, now),
|
|
None => self.composer = Some(Composer::new(loaded)),
|
|
}
|
|
self.feeds_for();
|
|
self.source = source;
|
|
if let Ok(mut status) = self.status.lock() {
|
|
status.revision = self.revision;
|
|
status.playing_revision = self.revision;
|
|
status.diags.clear();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn publish(&mut self) {
|
|
let clock = self.config.clock.now();
|
|
let Ok(mut status) = self.status.lock() else { return };
|
|
if let Some((beat, bpm, playing)) = clock {
|
|
status.beat = beat;
|
|
status.bpm = bpm;
|
|
status.playing = playing;
|
|
}
|
|
status.dropped = self.dropped;
|
|
let Some(composer) = &mut self.composer else { return };
|
|
status.label = composer.doc.label.clone();
|
|
status.sig = composer.doc.clock.sig;
|
|
status.section = composer.section_at(status.beat);
|
|
status.song_beat = if status.playing { composer.song_beat(status.beat) } else { self.seek_stopped };
|
|
status.looping = composer.loop_range;
|
|
status.params = composer.params.clone();
|
|
status.loops = composer.loops.iter().map(|s| LoopView {
|
|
name: s.name.clone(), key: s.key, cycle: s.cycle, start: s.last_start, len: s.last_len, notes: s.last_notes.clone(), durs: s.last_durs.clone(), lyrics: s.last_lyrics.iter().map(|y| (y.beat, y.text.clone())).collect(), logs: s.last_logs.clone(), resting: s.resting, error: s.error.clone(),
|
|
}).collect();
|
|
let keep = composer.history.len().saturating_sub(512);
|
|
status.recent = composer.history.iter().skip(keep).cloned().collect();
|
|
status.cues = composer.cues.iter().cloned().collect();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
struct Playing;
|
|
impl ClockSource for Playing {
|
|
fn now(&self) -> Option<(f64, f64, bool)> {
|
|
Some((0.0, 120.0, true))
|
|
}
|
|
}
|
|
|
|
/// Without a thread the host's pumps run the composer: the source
|
|
/// loads, the loops' first cycles are generated and the status
|
|
/// published.
|
|
#[test]
|
|
fn a_pumped_score_plays_without_a_thread() {
|
|
let config = LiveConfig { instance: "pumped_test".into(), lookahead: 0.15, clock: Arc::new(Playing) };
|
|
let live = LiveScore::new_pumped(config, "pumped_test_clock");
|
|
live.set_source("use mod.score.*\nlet clock = Clock{bpm: 120}\nlet s = Synth{preset: @pluck}\nlet l = Loop{play: s beats: 1 code: fn(i) { play(@c4) sleep(1) }}\nScore{clock s l}\n");
|
|
assert!(live.status().loops.is_empty(), "nothing runs before a pump");
|
|
for _ in 0..3 {
|
|
live.pump();
|
|
}
|
|
let status = live.status();
|
|
assert!(status.diags.is_empty(), "{:?}", status.diags);
|
|
assert_eq!(status.playing_revision, 1);
|
|
assert!(status.loops.iter().any(|l| l.name == "l" && !l.notes.is_empty()), "{:?}", status.loops);
|
|
}
|
|
}
|