///|
fn main raise {
let args = @env.args()
match find_command(args) {
Some(index) => run_command(args, index)
None => print_help()
}
}
///|
fn run_command(args : Array[String], index : Int) -> Unit raise {
match args[index] {
"demo" => run_demo()
"fft" => run_fft(args, index)
"analyze" => run_analyze(args, index)
"window" => run_window(args, index)
"convolve" => run_convolve(args, index)
"stft" => run_stft(args, index)
"psd" => run_psd(args, index)
"resample" => run_resample(args, index)
"biquad" => run_biquad(args, index)
"stats" => run_stats(args, index)
"correlate" => run_correlate(args, index)
"welch" => run_welch(args, index)
"peaks" => run_peaks(args, index)
"help" | "--help" | "-h" => print_help()
other => {
println("unknown command: \{other}")
print_help()
}
}
}
///|
fn print_help() -> Unit {
println("MoonSpectrum scientific signal processing CLI")
println("")
println("Commands:")
println(" demo")
println(" fft --sample-rate ")
println(" analyze --sample-rate ")
println(" window ")
println(" convolve ")
println(
" stft --window-length --hop-size --window ",
)
println(" psd --sample-rate ")
println(" resample --target-length ")
println(
" biquad --type --cutoff --sample-rate ",
)
println(" stats ")
println(" correlate [other-csv-file] --max-lag ")
println(
" welch --sample-rate --segment-length --overlap ",
)
println(" peaks --threshold --min-distance ")
}
///|
fn find_command(args : Array[String]) -> Int? {
for i in 0.. break Some(i)
_ => continue
}
} nobreak {
None
}
}
///|
fn run_demo() -> Unit raise {
let signal = @ms.sine_wave(length=8, sample_rate=8.0, frequency=2.0)
let spectrum = @ms.fft(@ms.real_signal(signal))
let peak = @ms.dominant_peak(spectrum, 8.0)
println(
"{\"demo\":\"sine\",\"samples\":\{double_array_json(signal)},\"dominant_frequency\":\{peak.frequency},\"peak_bin\":\{peak.index}}",
)
}
///|
fn run_fft(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="fft requires a csv file")
}
let sample_rate = flag_double(args, "--sample-rate", 1.0)
let values = read_csv_column(args[index + 1])
let spectrum = @ms.fft(@ms.real_signal(values))
let magnitudes = @ms.magnitude_spectrum(spectrum)
println(
"{\"sample_rate\":\{sample_rate},\"magnitudes\":\{double_array_json(magnitudes)}}",
)
}
///|
fn run_analyze(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="analyze requires a csv file")
}
let sample_rate = flag_double(args, "--sample-rate", 1.0)
let values = read_csv_column(args[index + 1])
let spectrum = @ms.fft(@ms.real_signal(values))
let peak = @ms.dominant_peak(spectrum, sample_rate)
println(
"{\"samples\":\{values.length()},\"sample_rate\":\{sample_rate},\"dominant_bin\":\{peak.index},\"dominant_frequency\":\{peak.frequency},\"magnitude\":\{peak.magnitude}}",
)
}
///|
fn run_window(args : Array[String], index : Int) -> Unit raise {
guard index + 2 < args.length() else {
raise @ms.InvalidArgument(message="window requires kind and length")
}
let kind = parse_window_kind(args[index + 1])
let length = parse_double(args[index + 2].to_string_view()).to_int()
let weights = @ms.window(kind, length)
println(
"{\"window\":\"\{args[index + 1]}\",\"weights\":\{double_array_json(weights)}}",
)
}
///|
fn run_convolve(args : Array[String], index : Int) -> Unit raise {
guard index + 2 < args.length() else {
raise @ms.InvalidArgument(
message="convolve requires signal and kernel csv files",
)
}
let signal = read_csv_column(args[index + 1])
let kernel = read_csv_column(args[index + 2])
let out = @ms.convolve(signal, kernel)
println("{\"result\":\{double_array_json(out)}}")
}
///|
fn flag_double(
args : Array[String],
flag : String,
default : Double,
) -> Double raise {
for i in 0.. Array[Double] raise {
let text = @fs.read_file_to_string(path)
let values : Array[Double] = []
for raw_line in text.split("\n") {
let line = raw_line.trim()
if line != "" {
let first = match line.split_once(",") {
Some((head, _)) => head.trim()
None => line
}
values.push(parse_double(first))
}
}
values
}
///|
fn parse_window_kind(input : String) -> @ms.WindowKind raise @ms.SpectrumError {
match input.to_lower() {
"rectangular" | "rect" => Rectangular
"hann" => Hann
"hamming" => Hamming
"blackman" => Blackman
_ => raise InvalidArgument(message="unknown window kind")
}
}
///|
fn double_array_json(values : ArrayView[Double]) -> String {
"[\{values.map(item => item.to_string()).join(",")}]"
}
///|
fn parse_double(input : StringView) -> Double raise {
@strconv.from_str(input)
}
///|
fn flag_int(args : Array[String], flag : String, default : Int) -> Int raise {
for i in 0.. String {
for i in 0.. Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="stft requires a csv file")
}
let window_length = flag_int(args, "--window-length", 256)
let hop_size = flag_int(args, "--hop-size", 128)
let window_str = flag_string(args, "--window", "hann")
let window_kind = parse_window_kind(window_str)
let values = read_csv_column(args[index + 1])
let res = @ms.stft(values, window_kind, window_length, hop_size)
let mut frames_json = "["
for i in 0.. Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="psd requires a csv file")
}
let sample_rate = flag_double(args, "--sample-rate", 1.0)
let values = read_csv_column(args[index + 1])
let two_sided = @ms.periodogram(values, sample_rate, window_kind=Rectangular)
let single = @ms.single_sided_psd(two_sided)
println("{\"psd_single_sided\":\{double_array_json(single)}}")
}
///|
fn run_resample(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="resample requires a csv file")
}
let target_length = flag_int(args, "--target-length", 128)
let values = read_csv_column(args[index + 1])
let res_lin = @ms.resample_linear(values, target_length)
println("{\"resampled_linear\":\{double_array_json(res_lin)}}")
}
///|
fn run_biquad(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="biquad requires a csv file")
}
let filter_type = flag_string(args, "--type", "lowpass")
let cutoff = flag_double(args, "--cutoff", 100.0)
let sample_rate = flag_double(args, "--sample-rate", 1000.0)
let values = read_csv_column(args[index + 1])
let lp = match filter_type {
"lowpass" => @ms.Biquad::lowpass(cutoff, sample_rate)
"highpass" => @ms.Biquad::highpass(cutoff, sample_rate)
"bandpass" => @ms.Biquad::bandpass(cutoff, sample_rate)
other => raise @ms.InvalidArgument(message="unknown biquad type: " + other)
}
let res = lp.filter(values)
println("{\"filtered\":\{double_array_json(res)}}")
}
///|
fn run_stats(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="stats requires a csv file")
}
let values = read_csv_column(args[index + 1])
let stats = @ms.signal_stats(values)
println(
"{\"count\":\{stats.count},\"mean\":\{stats.mean},\"variance\":\{stats.variance},\"stddev\":\{stats.stddev},\"rms\":\{stats.rms},\"minimum\":\{stats.minimum},\"maximum\":\{stats.maximum},\"peak_to_peak\":\{stats.peak_to_peak}}",
)
}
///|
fn run_correlate(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="correlate requires a csv file")
}
let signal = read_csv_column(args[index + 1])
let max_lag = flag_int(args, "--max-lag", signal.length() - 1)
if index + 2 < args.length() && args[index + 2] != "--max-lag" {
let other = read_csv_column(args[index + 2])
let result = @ms.cross_correlation(signal, other, max_lag)
println("{\"kind\":\"cross\",\"correlation\":\{double_array_json(result)}}")
} else {
let result = @ms.normalized_correlation(signal, max_lag)
println(
"{\"kind\":\"auto-normalized\",\"correlation\":\{double_array_json(result)}}",
)
}
}
///|
fn run_welch(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="welch requires a csv file")
}
let sample_rate = flag_double(args, "--sample-rate", 1.0)
let segment_length = flag_int(args, "--segment-length", 256)
let overlap = flag_double(args, "--overlap", 0.5)
let window_kind = parse_window_kind(flag_string(args, "--window", "hann"))
let values = read_csv_column(args[index + 1])
let result = @ms.welch_psd(
values,
sample_rate~,
segment_length~,
overlap~,
window_kind~,
)
println(
"{\"frequencies\":\{double_array_json(result.frequencies)},\"psd\":\{double_array_json(result.psd)}}",
)
}
///|
fn run_peaks(args : Array[String], index : Int) -> Unit raise {
guard index + 1 < args.length() else {
raise @ms.InvalidArgument(message="peaks requires a csv file")
}
let threshold = flag_double(args, "--threshold", 0.0)
let min_distance = flag_int(args, "--min-distance", 1)
let values = read_csv_column(args[index + 1])
let peaks = @ms.find_peaks(values, threshold~, min_distance~)
println("{\"peaks\":\{signal_peaks_json(peaks)}}")
}
///|
fn signal_peaks_json(peaks : ArrayView[@ms.SignalPeak]) -> String {
let items = peaks.map(peak => {
"{\"index\":\{peak.index},\"value\":\{peak.value}}"
})
"[" + items.join(",") + "]"
}