1mod bridge;
9
10pub use bridge::{pump_mcp, serve_mcp};
11
12use unlit_ecs::mcp::serde_json::{self, Value};
13use unlit_ecs::mcp::{
14 CommandSender, EncodedAudio, McpServer, Output, ToolResult, ToolRouter, ToolSource, WorldArg,
15 command, run, tool,
16};
17use unlit_ecs::{Entity, World, bundle};
18use unlit3d::winit::builtin::ExitRequest;
19
20use crate::record::{RecordingError, RecordingFormat, RecordingHandle, start_recording};
21
22#[derive(Debug, thiserror::Error)]
27enum ToolError {
28 #[error("the host has no exit request")]
30 NoExitRequest,
31 #[error("a recording is already in progress on this world")]
33 AlreadyRecording,
34 #[error("no recording is in progress on this world")]
36 NoRecording,
37 #[error("the recording captured no fill")]
39 EmptyRecording,
40 #[error(transparent)]
42 Recording(#[from] RecordingError),
43 #[error(transparent)]
45 Serialize(#[from] serde_json::Error),
46 #[error("{0}")]
48 Write(String),
49}
50
51const DEFAULT_RECORDING_SECONDS: f32 = 60.0;
53
54struct ActiveRecording(RecordingHandle);
57
58unlit_ecs::mcp::inventory::submit! {
59 ToolSource { extend: register }
60}
61
62fn register(router: &mut ToolRouter<McpServer>) {
64 router.add_route(tool(
65 "start_audio_recording",
66 "Start recording the mix an audio world fills. The recording runs until it is stopped or cancelled, or until it reaches its length limit; query it with audio_recording_status.",
67 start_audio_recording,
68 ));
69 router.add_route(tool(
70 "stop_audio_recording",
71 "Stop an audio world's recording and write what it captured to a file in a directory, returning the written path and size. The file name's extension picks the format: .flac (lossless, the default when it has none) or .wav. Prefer this over read_audio_recording, which returns the mix inline.",
72 stop_audio_recording,
73 ));
74 router.add_route(tool(
75 "cancel_audio_recording",
76 "Stop an audio world's recording and discard what it captured.",
77 cancel_audio_recording,
78 ));
79 router.add_route(tool(
80 "read_audio_recording",
81 "Stop an audio world's recording and return its captured mix as an audio content block, without writing a file. The format argument picks the encoding: \"flac\" (lossless, the default) or \"wav\".",
82 read_audio_recording,
83 ));
84 router.add_route(tool(
85 "request_exit",
86 "Ask the host to exit normally. The host must carry an ExitRequest component.",
87 request_host_exit,
88 ));
89 router.add_route(tool(
90 "audio_recording_status",
91 "Report what an audio world's recording has captured so far, or that none is in progress.",
92 audio_recording_status,
93 ));
94}
95
96async fn start_audio_recording(commands: CommandSender, args: StartRecording) -> ToolResult {
98 command(&commands, args.world, move |world| {
99 start(world, args.duration_seconds).map_err(|error| error.to_string())
100 })
101 .await
102}
103
104async fn stop_audio_recording(commands: CommandSender, args: StopRecording) -> ToolResult {
106 command(&commands, args.world, move |world| {
107 stop(world, &args.directory, args.file_name.as_deref()).map_err(|error| error.to_string())
108 })
109 .await
110}
111
112async fn read_audio_recording(commands: CommandSender, args: ReadRecording) -> ToolResult {
114 run(&commands, args.world, move |world| {
115 read(world, args.format.as_deref()).map_err(|error| error.to_string())
116 })
117 .await
118}
119
120async fn request_host_exit(commands: CommandSender, args: WorldArg) -> ToolResult {
122 command(&commands, args.world, |world| {
123 request_exit(world).map_err(|error| error.to_string())
124 })
125 .await
126}
127
128async fn cancel_audio_recording(commands: CommandSender, args: WorldArg) -> ToolResult {
130 command(&commands, args.world, |world| {
131 cancel(world).map_err(|error| error.to_string())
132 })
133 .await
134}
135
136async fn audio_recording_status(commands: CommandSender, args: WorldArg) -> ToolResult {
138 command(&commands, args.world, |world| {
139 status(world).map_err(|error| error.to_string())
140 })
141 .await
142}
143
144fn request_exit(world: &mut World) -> Result<Value, ToolError> {
146 let mut found = false;
147 for request in world.root_scope().query::<&mut ExitRequest>().iter() {
148 request.0 = true;
149 found = true;
150 }
151 if !found {
152 return Err(ToolError::NoExitRequest);
153 }
154 Ok(serde_json::json!({ "requested": true }))
155}
156
157fn start(world: &mut World, duration_seconds: Option<f32>) -> Result<Value, ToolError> {
159 if world
160 .root_scope()
161 .query::<&ActiveRecording>()
162 .iter()
163 .next()
164 .is_some()
165 {
166 return Err(ToolError::AlreadyRecording);
167 }
168 let seconds = duration_seconds.unwrap_or(DEFAULT_RECORDING_SECONDS);
169 let handle = start_recording(world, seconds);
170 let report = Started {
171 duration_seconds: seconds,
172 };
173 world
174 .root_scope_mut()
175 .spawn(bundle![ActiveRecording(handle)]);
176 Ok(serde_json::to_value(report)?)
177}
178
179fn stop(world: &mut World, directory: &str, file_name: Option<&str>) -> Result<Value, ToolError> {
181 let name = unlit_ecs::mcp::saved_name(file_name, "audio", "flac");
185 let format = RecordingFormat::of(&name)?;
186 let handle = take(world)?;
187 let Some(audio) = handle.stop(world) else {
188 return Err(ToolError::EmptyRecording);
189 };
190 let bytes = format.encode(audio.format, &audio.samples)?;
191 let saved = unlit_ecs::mcp::write_file(directory, &name, &bytes).map_err(ToolError::Write)?;
192 let report = Recorded {
193 path: saved.path,
194 size: saved.size,
195 frames: audio.frame_count(),
196 seconds: audio.seconds(),
197 sample_rate: audio.format.sample_rate,
198 channels: audio.format.channels,
199 };
200 Ok(serde_json::to_value(report)?)
201}
202
203fn read(world: &mut World, format: Option<&str>) -> Result<Output, ToolError> {
205 use base64::Engine as _;
206
207 let format = match format {
208 Some(name) => RecordingFormat::from_name(name)?,
209 None => RecordingFormat::DEFAULT,
210 };
211 let handle = take(world)?;
212 let Some(audio) = handle.stop(world) else {
213 return Err(ToolError::EmptyRecording);
214 };
215 let bytes = format.encode(audio.format, &audio.samples)?;
216 Ok(Output::Audio(EncodedAudio {
217 data: base64::engine::general_purpose::STANDARD.encode(&bytes),
218 mime_type: format.mime_type().to_owned(),
219 structured: serde_json::json!({
220 "size": bytes.len(),
221 "frames": audio.frame_count(),
222 "seconds": audio.seconds(),
223 "sample_rate": audio.format.sample_rate,
224 "channels": audio.format.channels,
225 }),
226 }))
227}
228
229fn cancel(world: &mut World) -> Result<Value, ToolError> {
231 take(world)?.cancel(world);
232 Ok(serde_json::json!({ "cancelled": true }))
233}
234
235fn status(world: &mut World) -> Result<Value, ToolError> {
237 let mut active_recording_rows = world.root_scope().query::<&ActiveRecording>();
238
239 let Some(recording) = active_recording_rows
240 .iter()
241 .next()
242 .map(|active_recording| active_recording.0.clone())
243 else {
244 return Ok(serde_json::json!({ "recording": false }));
245 };
246 let format = recording.format();
247 let report = InProgress {
248 recording: true,
249 sample_rate: format.map(|format| format.sample_rate),
250 channels: format.map(|format| format.channels),
251 frames: recording.frame_count(),
252 seconds: recording.seconds(),
253 full: recording.is_full(),
254 };
255 Ok(serde_json::to_value(report)?)
256}
257
258fn take(world: &mut World) -> Result<RecordingHandle, ToolError> {
260 let entity = world
261 .root_scope()
262 .query::<(&ActiveRecording, Entity)>()
263 .iter()
264 .next()
265 .map(|(_active_recording, entity)| entity)
266 .ok_or(ToolError::NoRecording)?;
267 let handle = {
268 let mut recording = world
269 .root_scope()
270 .query_one::<&ActiveRecording>(entity)
271 .expect("the recording entity exists");
272 recording.get().0.clone()
273 };
274 world.root_scope_mut().despawn(entity).unwrap();
275 Ok(handle)
276}
277
278#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
280pub(crate) struct StartRecording {
281 #[serde(default)]
284 pub world: unlit_ecs::mcp::WorldId,
285 #[serde(default)]
288 pub duration_seconds: Option<f32>,
289}
290
291#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
293pub(crate) struct StopRecording {
294 #[serde(default)]
297 pub world: unlit_ecs::mcp::WorldId,
298 pub directory: String,
300 #[serde(default)]
303 pub file_name: Option<String>,
304}
305
306#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
308pub(crate) struct ReadRecording {
309 #[serde(default)]
312 pub world: unlit_ecs::mcp::WorldId,
313 #[serde(default)]
316 pub format: Option<String>,
317}
318
319#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
321struct Started {
322 duration_seconds: f32,
324}
325
326#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
328struct InProgress {
329 recording: bool,
331 sample_rate: Option<u32>,
333 channels: Option<u16>,
335 frames: usize,
337 seconds: f64,
339 full: bool,
341}
342
343#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
345struct Recorded {
346 path: String,
348 size: usize,
350 frames: usize,
352 seconds: f64,
354 sample_rate: u32,
356 channels: u16,
358}
359
360#[cfg(all(test, feature = "audio"))]
361mod tests {
362 use super::*;
363 use crate::audio::{
364 AudioChunk, AudioFillOrder, AudioFormat, GlobalAudioGain, OnAudioFill, dispatch_audio_fill,
365 };
366
367 fn audio_world(level: f32) -> (World, AudioFormat) {
370 let mut world = World::new();
371 world.root_scope_mut().spawn(bundle![GlobalAudioGain(1.0)]);
372 world.root_scope_mut().spawn(bundle![OnAudioFill::new(
373 AudioFillOrder(0),
374 move |_world, _entity, chunk: &mut AudioChunk| chunk.out.fill(level),
375 ),]);
376 let format = AudioFormat {
377 sample_rate: 8,
378 channels: 1,
379 };
380 (world, format)
381 }
382
383 #[test]
384 fn a_recording_started_and_stopped_writes_its_mix() {
385 let (mut world, format) = audio_world(0.5);
386 let started = start(&mut world, Some(1.0)).expect("the recording starts");
387 assert_eq!(started["duration_seconds"], 1.0);
388
389 let mut out = [0.0f32; 4];
390 dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
391 let status = status(&mut world).expect("a recording reports");
392 assert_eq!(status["recording"], true);
393 assert_eq!(status["frames"], 4);
394 assert_eq!(status["sample_rate"], 8);
395
396 let directory = std::env::temp_dir().join(format!("unlit3dx-mcp-{}", std::process::id()));
397 let _ = std::fs::remove_dir_all(&directory);
398 let recorded = stop(&mut world, &directory.to_string_lossy(), Some("take.wav"))
399 .expect("the recording is written");
400 assert_eq!(recorded["frames"], 4);
401 assert_eq!(recorded["channels"], 1);
402 assert_eq!(recorded["sample_rate"], 8);
403 let path = recorded["path"].as_str().expect("the file has a path");
404 assert!(
405 path.ends_with("take.wav"),
406 "the file keeps its name: {path}"
407 );
408 let bytes = std::fs::read(path).expect("the file is written");
409 assert_eq!(&bytes[0..4], b"RIFF");
410 assert_eq!(bytes.len(), 44 + 8);
411 }
412
413 #[test]
414 fn a_name_without_an_extension_is_written_as_flac() {
415 let (mut world, format) = audio_world(0.5);
416 start(&mut world, Some(1.0)).expect("the recording starts");
417 let mut out = [0.0f32; 4];
418 dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
419
420 let directory =
421 std::env::temp_dir().join(format!("unlit3dx-mcp-{}-flac", std::process::id()));
422 let _ = std::fs::remove_dir_all(&directory);
423 let recorded = stop(&mut world, &directory.to_string_lossy(), Some("take"))
424 .expect("the recording is written");
425 let path = recorded["path"].as_str().expect("the file has a path");
426 assert!(
427 path.ends_with("take.flac"),
428 "the name is given the default extension: {path}"
429 );
430 let bytes = std::fs::read(path).expect("the file is written");
431 assert_eq!(&bytes[0..4], b"fLaC");
432 }
433
434 #[test]
435 fn a_name_the_server_cannot_write_leaves_the_recording_in_the_world() {
436 let (mut world, format) = audio_world(0.5);
437 start(&mut world, Some(1.0)).expect("the recording starts");
438 let mut out = [0.0f32; 4];
439 dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
440
441 let directory =
442 std::env::temp_dir().join(format!("unlit3dx-mcp-{}-refused", std::process::id()));
443 let _ = std::fs::remove_dir_all(&directory);
444 let refused = stop(&mut world, &directory.to_string_lossy(), Some("take.mp3"))
445 .expect_err("an extension the server cannot write is refused")
446 .to_string();
447 assert!(refused.contains("mp3"), "{refused}");
448 assert_eq!(
449 status(&mut world).expect("the recording is still there")["frames"],
450 4,
451 "a refused name keeps the mix it could not write"
452 );
453
454 let recorded = stop(&mut world, &directory.to_string_lossy(), Some("take.flac"))
455 .expect("a name the server can write finishes the recording");
456 assert_eq!(recorded["frames"], 4);
457 }
458
459 #[test]
460 fn a_finished_recording_is_answered_with_its_sound() {
461 use base64::Engine as _;
462
463 let (mut world, format) = audio_world(0.5);
464 start(&mut world, Some(1.0)).expect("the recording starts");
465 let mut out = [0.0f32; 4];
466 dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
467
468 let Output::Audio(sound) = read(&mut world, None).expect("the recording is read") else {
469 panic!("the answer carries a sound");
470 };
471 assert_eq!(sound.mime_type, "audio/flac");
472 assert_eq!(sound.structured["frames"], 4);
473 let flac = base64::engine::general_purpose::STANDARD
474 .decode(&sound.data)
475 .expect("the sound is base64");
476 assert_eq!(&flac[0..4], b"fLaC");
477
478 start(&mut world, Some(1.0)).expect("the recording starts");
479 dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
480 let Output::Audio(sound) = read(&mut world, Some("wav")).expect("the recording is read")
481 else {
482 panic!("the answer carries a sound");
483 };
484 assert_eq!(sound.mime_type, "audio/wav");
485 let wav = base64::engine::general_purpose::STANDARD
486 .decode(&sound.data)
487 .expect("the sound is base64");
488 assert_eq!(&wav[0..4], b"RIFF");
489 }
490
491 #[test]
492 fn a_second_recording_is_refused_until_the_first_ends() {
493 let (mut world, _) = audio_world(0.0);
494 start(&mut world, None).expect("the first recording starts");
495 let refused = start(&mut world, None)
496 .expect_err("the second is refused")
497 .to_string();
498 assert!(refused.contains("already in progress"), "{refused}");
499 cancel(&mut world).expect("the recording is cancelled");
500 start(&mut world, None).expect("a fresh recording starts");
501 }
502
503 #[test]
504 fn a_cancelled_or_finished_recording_reports_nothing() {
505 let (mut world, _) = audio_world(0.0);
506 assert!(status(&mut world).expect("no recording is fine")["recording"] == false);
507 let missing = cancel(&mut world)
508 .expect_err("there is nothing to cancel")
509 .to_string();
510 assert!(missing.contains("no recording"), "{missing}");
511 }
512
513 #[test]
514 fn a_recording_that_saw_no_fill_is_reported_when_stopped() {
515 let (mut world, _) = audio_world(0.0);
516 start(&mut world, Some(1.0)).expect("the recording starts");
517 let directory =
518 std::env::temp_dir().join(format!("unlit3dx-mcp-empty-{}", std::process::id()));
519 let failed = stop(&mut world, &directory.to_string_lossy(), None)
520 .expect_err("a recording with no fill cannot be written")
521 .to_string();
522 assert!(failed.contains("captured no fill"), "{failed}");
523 }
524}