use std::path::PathBuf;
use std::sync::Arc;
use dawkey_core::{COMMAND_SPECS, CommandSpec, Effect, TrackId};
use rmcp::model::{Tool, ToolAnnotations};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
pub(crate) const GET_STATE: &str = "get_state";
pub(crate) const UNDO: &str = "undo";
pub(crate) const REDO: &str = "redo";
pub(crate) const NEW_PROJECT: &str = "new_project";
pub(crate) const OPEN_PROJECT: &str = "open_project";
pub(crate) const SAVE_PROJECT: &str = "save_project";
pub(crate) const IMPORT_AUDIO: &str = "import_audio";
pub(crate) const IMPORT_MIDI: &str = "import_midi";
pub(crate) const PLAY: &str = "play";
pub(crate) const STOP: &str = "stop";
pub(crate) const RECORD: &str = "record";
pub(crate) const SET_RECORD_OFFSET: &str = "set_record_offset";
pub(crate) const LOCATE: &str = "locate";
pub(crate) const GET_ENGINE_STATUS: &str = "get_engine_status";
pub(crate) const GET_METERS: &str = "get_meters";
pub(crate) const ADVANCE_CLOCK: &str = "advance_clock";
pub(crate) const RENDER_RANGE: &str = "render_range";
pub(crate) const EXPORT_AUDIO: &str = "export_audio";
pub(crate) const UI_TREE: &str = "ui_tree";
pub(crate) const UI_CLICK: &str = "ui_click";
pub(crate) const UI_SCREENSHOT: &str = "ui_screenshot";
pub(crate) const UI_RESIZE: &str = "ui_resize";
pub(crate) const UI_DRAG: &str = "ui_drag";
pub(crate) const UI_SCROLL: &str = "ui_scroll";
pub(crate) const UI_HOVER: &str = "ui_hover";
pub(crate) const UI_KEY: &str = "ui_key";
pub(crate) const UI_TYPE: &str = "ui_type";
pub(crate) const UI_DROP_FILES: &str = "ui_drop_files";
pub(crate) const LIST_PLUGINS: &str = "list_plugins";
pub(crate) const RESCAN_PLUGINS: &str = "rescan_plugins";
pub(crate) const RELOAD_PLUGIN: &str = "reload_plugin";
pub(crate) const SHOW_PLUGIN_GUI: &str = "show_plugin_gui";
pub(crate) const GET_PLUGIN: &str = "get_plugin";
pub(crate) const SET_PLUGIN_PROPERTY: &str = "set_plugin_property";
pub(crate) const GET_CHANGES: &str = "get_changes";
pub(crate) const BATCH: &str = "batch";
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct NoArgs {}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct GetState {
#[serde(default = "yes")]
pub(crate) notes: bool,
#[serde(default = "yes")]
pub(crate) controls: bool,
#[serde(default = "yes")]
pub(crate) automation: bool,
#[serde(default)]
pub(crate) tracks: Option<Vec<TrackId>>,
#[serde(default)]
pub(crate) summary: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct GetChanges {
pub(crate) since_revision: u64,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct NewProject {
#[serde(default)]
pub(crate) name: Option<String>,
#[serde(default)]
pub(crate) discard_changes: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct OpenProject {
pub(crate) path: PathBuf,
#[serde(default)]
pub(crate) discard_changes: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct SaveProject {
#[serde(default)]
pub(crate) path: Option<PathBuf>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ImportAudio {
pub(crate) path: PathBuf,
#[serde(default)]
pub(crate) track: Option<TrackId>,
#[serde(default)]
pub(crate) position_s: f64,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ImportMidi {
pub(crate) path: PathBuf,
#[serde(default)]
pub(crate) position_s: f64,
#[serde(default)]
pub(crate) targets: Vec<ImportMidiTarget>,
#[serde(default = "yes")]
pub(crate) replace: bool,
#[serde(default)]
pub(crate) use_file_tempo: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ImportMidiTarget {
#[serde(default)]
pub(crate) file_track: Option<usize>,
#[serde(default)]
pub(crate) file_track_name: Option<String>,
pub(crate) track: TrackId,
}
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PluginKind {
Instrument,
MidiEffect,
Effect,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ListPlugins {
#[serde(default)]
pub(crate) query: Option<String>,
#[serde(default)]
pub(crate) kind: Option<PluginKind>,
#[serde(default)]
pub(crate) params: bool,
#[serde(default)]
pub(crate) detail: bool,
#[serde(default)]
pub(crate) first: usize,
#[serde(default)]
pub(crate) count: Option<usize>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct GetPlugin {
pub(crate) plugin: dawkey_core::PluginId,
#[serde(default)]
pub(crate) first: usize,
#[serde(default)]
pub(crate) count: Option<usize>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct SetPluginProperty {
pub(crate) plugin: dawkey_core::PluginId,
pub(crate) key: String,
pub(crate) value: Value,
#[serde(default)]
pub(crate) kind: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ReloadPlugin {
pub(crate) plugin: dawkey_core::PluginId,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ShowPluginGui {
pub(crate) plugin: dawkey_core::PluginId,
#[serde(default = "yes")]
pub(crate) show: bool,
}
fn yes() -> bool {
true
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Locate {
pub(crate) position_s: f64,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct AdvanceClock {
pub(crate) seconds: f64,
#[serde(default)]
pub(crate) input_path: Option<PathBuf>,
#[serde(default)]
pub(crate) midi_input: Vec<MidiInputNote>,
#[serde(default)]
pub(crate) midi_controls: Vec<MidiInputControl>,
#[serde(default)]
pub(crate) midi_management: Vec<MidiInputManagement>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct MidiInputManagement {
pub(crate) at_s: f64,
pub(crate) pitch: u8,
#[serde(default)]
pub(crate) detach: bool,
#[serde(default)]
pub(crate) reset: bool,
#[serde(default)]
pub(crate) device: Option<String>,
#[serde(default)]
pub(crate) channel: Option<u8>,
}
impl MidiInputManagement {
pub(crate) fn event(&self) -> Result<(f64, [u8; 3], u32), String> {
if self.at_s < 0.0 || !self.at_s.is_finite() {
return Err("at_s must be at least 0".into());
}
if self.pitch > 127 {
return Err(format!("pitch must be 0 to 127, got {}", self.pitch));
}
let channel = self.channel.unwrap_or(1);
if !(1..=16).contains(&channel) {
return Err(format!("channel must be 1 to 16, got {channel}"));
}
let flags = if self.detach {
dawkey_core::PER_NOTE_DETACH
} else {
0
} | if self.reset {
dawkey_core::PER_NOTE_RESET
} else {
0
};
let status = dawkey_core::PER_NOTE_MANAGEMENT | (channel - 1);
Ok((self.at_s, [status, self.pitch, flags], 0))
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct MidiInputControl {
pub(crate) at_s: f64,
pub(crate) controller: dawkey_core::Controller,
pub(crate) value: u16,
#[serde(default)]
pub(crate) device: Option<String>,
#[serde(default)]
pub(crate) channel: Option<u8>,
}
impl MidiInputControl {
pub(crate) fn event(&self) -> Result<(f64, [u8; 3], u32), String> {
let c = self.controller;
if !c.valid() || self.value > c.max() {
return Err(format!(
"controller {c:?} is out of range, or its value isn't 0 to {}",
c.max()
));
}
if self.at_s < 0.0 || !self.at_s.is_finite() {
return Err("at_s must be at least 0".into());
}
let channel = self.channel.unwrap_or(1);
if !(1..=16).contains(&channel) {
return Err(format!("channel must be 1 to 16, got {channel}"));
}
let data = c.message(channel - 1, self.value);
Ok((self.at_s, data, c.value32(self.value)))
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct MidiInputNote {
pub(crate) at_s: f64,
pub(crate) pitch: u8,
pub(crate) length_s: f64,
#[serde(default)]
pub(crate) velocity: Option<u8>,
#[serde(default)]
pub(crate) device: Option<String>,
#[serde(default)]
pub(crate) channel: Option<u8>,
}
impl MidiInputNote {
pub(crate) fn events(&self) -> Result<[(f64, [u8; 3], u32); 2], String> {
let velocity = self.velocity.unwrap_or(100);
if self.pitch > 127 || !(1..=127).contains(&velocity) {
return Err(format!(
"pitch must be 0 to 127 and velocity 1 to 127, got {} and {velocity}",
self.pitch
));
}
if !(self.at_s >= 0.0 && self.length_s > 0.0) {
return Err("at_s must be at least 0 and length_s more than 0".into());
}
let channel = self.channel.unwrap_or(1);
if !(1..=16).contains(&channel) {
return Err(format!("channel must be 1 to 16, got {channel}"));
}
let channel = channel - 1;
Ok([
(self.at_s, [0x90 | channel, self.pitch, velocity], 0),
(
self.at_s + self.length_s,
[0x80 | channel, self.pitch, 0],
0,
),
])
}
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct SetRecordOffset {
pub(crate) ms: f64,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct RenderRange {
pub(crate) start_s: f64,
pub(crate) end_s: f64,
#[serde(default)]
pub(crate) output_path: Option<PathBuf>,
#[serde(default)]
pub(crate) tracks: Option<Vec<TrackId>>,
#[serde(default)]
pub(crate) stems: bool,
#[serde(default)]
pub(crate) stems_dir: Option<PathBuf>,
#[serde(default)]
pub(crate) segment_length_s: Option<f64>,
#[serde(default)]
pub(crate) segment_bars: Option<u32>,
#[serde(default)]
pub(crate) segments: Option<Vec<SegmentRange>>,
}
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct SegmentRange {
pub(crate) start_s: f64,
pub(crate) end_s: f64,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ExportAudio {
pub(crate) path: PathBuf,
#[serde(default)]
pub(crate) format: Option<ExportFormat>,
#[serde(default)]
pub(crate) bit_depth: Option<u32>,
#[serde(default)]
pub(crate) quality: Option<f32>,
#[serde(default)]
pub(crate) bitrate_kbps: Option<u32>,
#[serde(default)]
pub(crate) tags: Option<std::collections::BTreeMap<String, String>>,
#[serde(default)]
pub(crate) sample_rate: Option<u32>,
#[serde(default)]
pub(crate) start_s: Option<f64>,
#[serde(default)]
pub(crate) end_s: Option<f64>,
#[serde(default)]
pub(crate) tail_s: Option<f64>,
#[serde(default, rename = "loop")]
pub(crate) wrap: bool,
#[serde(default)]
pub(crate) tracks: Option<Vec<TrackId>>,
#[serde(default)]
pub(crate) stems: bool,
#[serde(default)]
pub(crate) segment_length_s: Option<f64>,
#[serde(default)]
pub(crate) segment_bars: Option<u32>,
#[serde(default)]
pub(crate) segments: Option<Vec<SegmentRange>>,
}
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ExportFormat {
Wav,
Flac,
Ogg,
Opus,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Button {
#[default]
Left,
Right,
Middle,
}
impl From<Button> for dawkey_ui::MouseButton {
fn from(b: Button) -> Self {
match b {
Button::Left => Self::Left,
Button::Right => Self::Right,
Button::Middle => Self::Middle,
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Mods {
#[serde(default)]
pub(crate) ctrl: bool,
#[serde(default)]
pub(crate) shift: bool,
#[serde(default)]
pub(crate) alt: bool,
}
impl From<Mods> for dawkey_ui::Modifiers {
fn from(m: Mods) -> Self {
Self {
ctrl: m.ctrl,
shift: m.shift,
alt: m.alt,
logo: false,
}
}
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiClick {
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(default)]
pub(crate) x: Option<f32>,
#[serde(default)]
pub(crate) y: Option<f32>,
#[serde(default)]
pub(crate) button: Button,
#[serde(default)]
pub(crate) modifiers: Mods,
#[serde(default)]
pub(crate) double: bool,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiDrag {
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(default)]
pub(crate) x: Option<f32>,
#[serde(default)]
pub(crate) y: Option<f32>,
#[serde(default)]
pub(crate) dx: f32,
#[serde(default)]
pub(crate) dy: f32,
#[serde(default)]
pub(crate) steps: Option<u32>,
#[serde(default)]
pub(crate) modifiers: Mods,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiHover {
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(default)]
pub(crate) x: Option<f32>,
#[serde(default)]
pub(crate) y: Option<f32>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiScroll {
pub(crate) x: f32,
pub(crate) y: f32,
#[serde(default)]
pub(crate) dx: f32,
#[serde(default)]
pub(crate) dy: f32,
#[serde(default)]
pub(crate) modifiers: Mods,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiKey {
pub(crate) key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiType {
pub(crate) text: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiDropFiles {
pub(crate) paths: Vec<PathBuf>,
pub(crate) x: f32,
pub(crate) y: f32,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiScreenshot {
#[serde(default)]
pub(crate) scale: Option<f32>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UiResize {
pub(crate) width: f32,
pub(crate) height: f32,
}
pub(crate) fn build_tools() -> Vec<Tool> {
let read_only = ToolAnnotations::new().read_only(true).open_world(false);
let mutating = ToolAnnotations::new()
.read_only(false)
.destructive(false)
.open_world(false);
let mut tools = vec![
tool::<GetState>(GET_STATE, "", read_only.clone()),
tool::<NoArgs>(UNDO, "Undo the last change.", mutating.clone()),
tool::<NoArgs>(REDO, "Redo the last undone change.", mutating.clone()),
tool::<NewProject>(NEW_PROJECT, "", mutating.clone().destructive(true)),
tool::<OpenProject>(OPEN_PROJECT, "", mutating.clone().destructive(true)),
tool::<SaveProject>(SAVE_PROJECT, "", mutating.clone().idempotent(true)),
tool::<ImportAudio>(IMPORT_AUDIO, "", mutating.clone().idempotent(false)),
tool::<ImportMidi>(IMPORT_MIDI, "", mutating.clone().idempotent(false)),
tool::<NoArgs>(PLAY, "Start playback.", mutating.clone().idempotent(true)),
tool::<NoArgs>(
STOP,
"Stop playback (and recording). The playhead stays where it is. When recording, \
the takes become clips on their tracks (one undo step); earlier loop passes are \
placed muted.",
mutating.clone().idempotent(true),
),
tool::<NoArgs>(
RECORD,
"Start recording on every armed track (set_track_arm), and playback. Respects \
the loop and punch ranges. Stop with stop.",
mutating.clone().idempotent(true),
),
tool::<SetRecordOffset>(SET_RECORD_OFFSET, "", mutating.clone().idempotent(true)),
tool::<Locate>(LOCATE, "", mutating.clone().idempotent(true)),
tool::<NoArgs>(
GET_ENGINE_STATUS,
"Get the audio engine's status: backend (virtual or pipewire), sample rate, \
transport (playing, position), PipeWire node state, and sources whose audio \
could not be loaded.",
read_only.clone(),
),
tool::<NoArgs>(
GET_METERS,
"Get peak levels (dBFS, null = silence) of the master bus and each track since \
the previous get_meters call.",
read_only.clone(),
),
tool::<AdvanceClock>(ADVANCE_CLOCK, "", mutating.clone()),
tool::<RenderRange>(RENDER_RANGE, "", mutating.clone().idempotent(true)),
tool::<ExportAudio>(EXPORT_AUDIO, "", mutating.clone().idempotent(true)),
tool::<NoArgs>(
UI_TREE,
"Get the window state (size, scale, maximized, client-side decorations) and its \
widget tree under `root`: stable IDs, roles, names, values, bounds (logical px), \
and toggle/enabled state.",
read_only.clone(),
),
tool::<UiClick>(UI_CLICK, "", mutating.clone()),
tool::<UiDrag>(UI_DRAG, "", mutating.clone()),
tool::<UiScroll>(UI_SCROLL, "", mutating.clone()),
tool::<UiHover>(UI_HOVER, "", mutating.clone()),
tool::<UiKey>(UI_KEY, "", mutating.clone()),
tool::<UiType>(UI_TYPE, "", mutating.clone()),
tool::<UiDropFiles>(UI_DROP_FILES, "", mutating.clone()),
tool::<UiScreenshot>(UI_SCREENSHOT, "", read_only.clone()),
tool::<UiResize>(UI_RESIZE, "", mutating.clone().idempotent(true)),
tool::<ListPlugins>(LIST_PLUGINS, "", read_only.clone()),
tool::<NoArgs>(
RESCAN_PLUGINS,
"Scan for CLAP, LV2 and VST3 plugins again (after installing or removing some). \
Each CLAP file and VST3 bundle is scanned in its own process, so a broken plugin \
is only reported. Returns how many were found, and what failed.",
mutating.clone().idempotent(true),
),
tool::<ReloadPlugin>(RELOAD_PLUGIN, "", mutating.clone().idempotent(true)),
tool::<ShowPluginGui>(SHOW_PLUGIN_GUI, "", mutating.clone().idempotent(true)),
tool::<GetPlugin>(GET_PLUGIN, "", read_only.clone()),
tool::<SetPluginProperty>(SET_PLUGIN_PROPERTY, "", mutating.clone().idempotent(true)),
tool::<GetChanges>(GET_CHANGES, "", read_only),
tool::<crate::batch::Batch>(BATCH, "", mutating),
];
tools.extend(COMMAND_SPECS.iter().map(command_tool));
tools
}
fn command_tool(spec: &CommandSpec) -> Tool {
let base = ToolAnnotations::new().read_only(false).open_world(false);
let annotations = match spec.effect {
Effect::Additive => base.destructive(false).idempotent(false),
Effect::Setter => base.destructive(false).idempotent(true),
Effect::Destructive => base.destructive(true).idempotent(false),
};
make_tool(spec.name, "", (spec.schema)(), annotations)
}
fn tool<T: JsonSchema>(
name: &'static str,
description: &str,
annotations: ToolAnnotations,
) -> Tool {
make_tool(
name,
description,
dawkey_core::args_schema::<T>(),
annotations,
)
}
fn make_tool(
name: &'static str,
description: &str,
schema: schemars::Schema,
annotations: ToolAnnotations,
) -> Tool {
let Value::Object(mut schema) = schema.to_value() else {
panic!("argument schema for {name} is not an object");
};
schema.remove("$schema");
schema.remove("title");
crate::times::augment_schema(&mut schema);
let doc = schema
.remove("description")
.and_then(|d| d.as_str().map(clean_doc));
let description = match (description, doc) {
("", Some(doc)) => doc,
(d, _) => d.to_owned(),
};
Tool::new(name, description, Arc::new(schema)).with_annotations(annotations)
}
fn clean_doc(doc: &str) -> String {
doc.split_whitespace().collect::<Vec<_>>().join(" ")
}