use clap::{Args, Parser, Subcommand};
use std::error::Error;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::Command;
use update_check::check_for_update;
mod audio;
mod fluid;
mod fx;
mod midi;
mod synth;
mod update_check;
fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
if cli.midi_config() != midi::MidiConfig::default()
&& matches!(
cli.command,
Some(CliCommand::Update | CliCommand::MidiPorts | CliCommand::Render(_))
)
{
return Err("MIDI port flags only apply to live playback".into());
}
let midi = cli.midi_config();
let bars = cli.bars.unwrap_or(fluid::DEFAULT_AUTO_BARS);
match cli.command {
None => match cli.song.as_deref() {
None => fluid::run(cli.osc, midi),
Some(song) => play_song(song, bars, cli.osc, midi),
},
Some(CliCommand::Update) => update_nooise(),
Some(CliCommand::MidiPorts) => midi::list_ports(),
Some(CliCommand::Render(args)) => render(args),
Some(CliCommand::Auto) => fluid::run_auto(bars, cli.osc, midi),
}
}
#[derive(Debug, Parser)]
#[command(
version,
about,
after_help = "Play a song: nooise <SONG>, where a song is a built-in number \
(nooise 9), several of them (nooise 9,10,11), or a shared code \
(nooise n1_...)."
)]
struct Cli {
#[command(subcommand)]
command: Option<CliCommand>,
song: Option<String>,
#[arg(long, global = true)]
bars: Option<u32>,
#[arg(
long,
global = true,
value_name = "ADDR",
num_args = 0..=1,
default_missing_value = fluid::DEFAULT_OSC_TARGET,
require_equals = true
)]
osc: Option<SocketAddr>,
#[arg(long, global = true, value_name = "PORT")]
midi: Option<String>,
#[arg(long, global = true, value_name = "PORT")]
midi_in: Option<String>,
#[arg(long, global = true, value_name = "PORT")]
midi_out: Option<String>,
#[arg(long, global = true, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=16))]
midi_in_channel: u8,
#[arg(long, global = true, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=16))]
midi_out_channel: u8,
}
impl Cli {
fn midi_config(&self) -> midi::MidiConfig<'_> {
midi::MidiConfig {
input: self
.midi_in
.as_deref()
.or(self.midi.as_deref())
.map(|name| midi::MidiEndpoint {
name,
channel: self.midi_in_channel,
}),
output: self
.midi_out
.as_deref()
.or(self.midi.as_deref())
.map(|name| midi::MidiEndpoint {
name,
channel: self.midi_out_channel,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Subcommand)]
enum CliCommand {
#[command(about = "Update nooise from crates.io", visible_alias = "upgrade")]
Update,
#[command(about = "List available MIDI input and output port names")]
MidiPorts,
#[command(about = "Render the default mix to a wav file")]
Render(RenderArgs),
#[command(about = "Morph through every built-in song, forever")]
Auto,
}
#[derive(Debug, Clone, PartialEq, Args)]
struct RenderArgs {
#[arg(long, default_value_t = 20.0)]
seconds: f32,
#[arg(long, default_value = "nooise.wav")]
out: PathBuf,
#[arg(long)]
seed: Option<u64>,
}
fn render(args: RenderArgs) -> Result<(), Box<dyn Error>> {
if args.seconds <= 0.0 {
return Err("--seconds must be positive".into());
}
fluid::render_wav(args.seconds, &args.out, args.seed)
}
fn play_song(
song: &str,
bars: u32,
osc: Option<SocketAddr>,
midi: midi::MidiConfig<'_>,
) -> Result<(), Box<dyn Error>> {
if song.starts_with(fluid::CODE_PREFIX) {
let state = fluid::decode_song_code(song).map_err(|error| error.to_string())?;
return fluid::run_with_song_state(state, osc, midi);
}
let numbers = song
.split(',')
.map(|part| {
part.trim()
.parse::<usize>()
.map_err(|_| format!("{part:?} is neither a song number nor an n1_ code").into())
})
.collect::<Result<Vec<usize>, Box<dyn Error>>>()?;
fluid::run_songs(&numbers, bars, osc, midi)
}
fn update_nooise() -> Result<(), Box<dyn Error>> {
println!("Checking crates.io for nooise updates...");
let Some(latest) = check_for_update()? else {
println!("nooise is up to date (v{})", env!("CARGO_PKG_VERSION"));
return Ok(());
};
let latest_version = latest.semver().to_string();
println!("Updating nooise to {latest}...");
let status = Command::new("cargo")
.args(cargo_install_args(&latest_version))
.status()
.map_err(|e| format!("failed to run cargo install nooise: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo install nooise failed with {status}").into())
}
}
fn cargo_install_args(version: &str) -> [&str; 6] {
[
"install",
"nooise",
"--locked",
"--version",
version,
"--force",
]
}
#[cfg(test)]
mod tests {
use super::{Cli, CliCommand, RenderArgs, cargo_install_args, render};
use clap::{CommandFactory, Parser, error::ErrorKind};
use std::path::PathBuf;
fn parse(items: &[&str]) -> Result<Cli, clap::Error> {
let args = std::iter::once("nooise").chain(items.iter().copied());
Cli::try_parse_from(args)
}
#[test]
fn no_args_runs_app() {
let cli = parse(&[]).unwrap();
assert_eq!(cli.command, None);
assert_eq!(cli.osc, None);
assert_eq!(cli.midi_out, None);
}
#[test]
fn midi_output_name_is_global_and_channel_is_implicitly_one() {
for args in [
&["--midi-out", "Take 5", "9"][..],
&["9", "--midi-out", "Take 5"],
&["auto", "--midi-out", "Take 5"],
] {
assert_eq!(parse(args).unwrap().midi_out.as_deref(), Some("Take 5"));
}
assert_eq!(
parse(&["midi-ports"]).unwrap().command,
Some(CliCommand::MidiPorts)
);
}
#[test]
fn midi_flag_selects_both_directions_with_independent_overrides() {
let cli = parse(&["--midi", "Take 5"]).unwrap();
let config = cli.midi_config();
assert_eq!(config.input.unwrap().name, "Take 5");
assert_eq!(config.output.unwrap().name, "Take 5");
assert_eq!(config.input.unwrap().channel, 1);
assert_eq!(config.output.unwrap().channel, 1);
let cli = parse(&[
"auto",
"--midi",
"Take 5",
"--midi-in",
"Keyboard",
"--midi-in-channel",
"2",
"--midi-out-channel",
"5",
])
.unwrap();
let config = cli.midi_config();
assert_eq!(config.input.unwrap().name, "Keyboard");
assert_eq!(config.input.unwrap().channel, 2);
assert_eq!(config.output.unwrap().name, "Take 5");
assert_eq!(config.output.unwrap().channel, 5);
assert!(parse(&["--midi-in-channel", "17"]).is_err());
}
#[test]
fn osc_target_defaults_to_foorm_and_accepts_an_address() {
let default = "127.0.0.1:9000".parse().ok();
for args in [
&["--osc"][..],
&["9", "--osc"],
&["--osc", "9"],
&["auto", "--osc"],
] {
let cli = parse(args).unwrap();
assert_eq!(cli.osc, default, "{args:?}");
}
assert_eq!(parse(&["--osc", "9"]).unwrap().song.as_deref(), Some("9"));
assert_eq!(
parse(&["--osc=10.0.0.5:7000", "9"]).unwrap().osc,
"10.0.0.5:7000".parse().ok()
);
assert_eq!(
parse(&["--osc=localhost"]).unwrap_err().kind(),
ErrorKind::ValueValidation
);
}
#[test]
fn a_song_is_one_positional_named_by_code_or_by_number() {
for (args, song, bars) in [
(vec!["n1_abc"], "n1_abc", None),
(vec!["9"], "9", None),
(vec!["9,10,11"], "9,10,11", None),
(vec!["9", "--bars", "4"], "9", Some(4)),
(vec!["--bars", "4", "9"], "9", Some(4)),
] {
let cli = parse(&args).unwrap();
assert_eq!(cli.song.as_deref(), Some(song), "{args:?}");
assert_eq!(cli.bars, bars, "{args:?}");
assert_eq!(cli.command, None, "{args:?}");
}
}
#[test]
fn subcommands_are_not_mistaken_for_songs() {
assert!(matches!(
parse(&["render"]).unwrap().command,
Some(CliCommand::Render(_))
));
assert_eq!(parse(&["auto"]).unwrap().command, Some(CliCommand::Auto));
assert_eq!(parse(&["render"]).unwrap().song, None);
}
#[test]
fn version_flags_are_available() {
assert_eq!(
parse(&["-V"]).unwrap_err().kind(),
ErrorKind::DisplayVersion
);
assert_eq!(
parse(&["--version"]).unwrap_err().kind(),
ErrorKind::DisplayVersion
);
}
#[test]
fn update_and_upgrade_run_updater() {
assert_eq!(
parse(&["update"]).unwrap().command,
Some(CliCommand::Update)
);
assert_eq!(
parse(&["upgrade"]).unwrap().command,
Some(CliCommand::Update)
);
}
#[test]
fn updater_installs_exact_latest_version() {
assert_eq!(
cargo_install_args("1.2.3"),
[
"install",
"nooise",
"--locked",
"--version",
"1.2.3",
"--force"
]
);
}
#[test]
fn unknown_arg_errors() {
assert!(parse(&["--experiment"]).is_err());
}
#[test]
fn render_defaults_and_flags_parse() {
assert_eq!(
parse(&["render"]).unwrap().command,
Some(CliCommand::Render(RenderArgs {
seconds: 20.0,
out: PathBuf::from("nooise.wav"),
seed: None,
}))
);
assert_eq!(
parse(&[
"render",
"--seconds",
"3.5",
"--out",
"/tmp/x.wav",
"--seed",
"42"
])
.unwrap()
.command,
Some(CliCommand::Render(RenderArgs {
seconds: 3.5,
out: PathBuf::from("/tmp/x.wav"),
seed: Some(42),
}))
);
}
#[test]
fn auto_takes_bars_only_as_a_flag() {
assert_eq!(parse(&["auto"]).unwrap().bars, None);
assert_eq!(parse(&["auto", "--bars", "32"]).unwrap().bars, Some(32));
assert!(parse(&["auto", "4"]).is_err());
}
#[test]
fn render_rejects_bad_input() {
assert!(parse(&["render", "--seconds"]).is_err());
assert!(parse(&["render", "--seconds", "abc"]).is_err());
assert!(parse(&["render", "--loud"]).is_err());
assert!(parse(&["update", "extra"]).is_err());
}
#[test]
fn render_rejects_non_positive_seconds() {
let err = render(RenderArgs {
seconds: 0.0,
out: PathBuf::from("/tmp/nooise-zero.wav"),
seed: None,
})
.unwrap_err()
.to_string();
assert!(err.contains("--seconds must be positive"));
}
#[test]
fn help_mentions_version_update_and_render() {
let help = Cli::command().render_help().to_string();
assert!(help.contains("--version"));
assert!(help.contains("update"));
assert!(help.contains("upgrade"));
assert!(help.contains("render"));
assert!(help.contains("nooise 9"));
assert!(help.contains("nooise 9,10,11"));
assert!(help.contains("nooise n1_..."));
}
}