diff --git a/Engine/crates/engine-play/src/audio_output.rs b/Engine/crates/engine-play/src/audio_output.rs index 3816c201..103b2719 100644 --- a/Engine/crates/engine-play/src/audio_output.rs +++ b/Engine/crates/engine-play/src/audio_output.rs @@ -1,52 +1,211 @@ -//! Device output is isolated from the scene and script locks. +//! Native device handles and streams stay on the audio thread, outside scene locks. #[derive(Default)] pub(crate) struct AudioOutput { #[cfg(windows)] - sender: Option>>, + sender: Option>, } + +#[cfg(windows)] +#[cfg_attr(test, allow(dead_code))] +enum Command { + Samples(Vec), + Devices { + input: bool, + reply: std::sync::mpsc::Sender>, + }, + Select { + input: bool, + id: Option, + reply: std::sync::mpsc::Sender>, + }, +} + impl AudioOutput { pub fn new() -> Self { - // Unit tests exercise the mixer without opening native audio devices. - // Hosted Windows runners have no reliable playback device, and parallel - // scene fixtures must not initialize device drivers for each test. + // Tests must not initialize native device drivers for each scene fixture. #[cfg(all(windows, not(test)))] { - let (sender, receiver) = std::sync::mpsc::sync_channel::>(4); - let _ = std::thread::Builder::new() + let (sender, receiver) = std::sync::mpsc::sync_channel(8); + if std::thread::Builder::new() .name("rustic-audio".into()) - .spawn(move || { - let Ok(stream) = rodio::OutputStreamBuilder::open_default_stream() else { - return; - }; - let sink = rodio::Sink::connect_new(stream.mixer()); - while let Ok(samples) = receiver.recv() { - if !samples.is_empty() && sink.len() < 4 { - sink.append(rodio::buffer::SamplesBuffer::new(2, 48_000, samples)); - } - } - }); - Self { - sender: Some(sender), + .spawn(move || native::run(&receiver)) + .is_ok() + { + return Self { + sender: Some(sender), + }; } } - #[cfg(any(not(windows), test))] - { - Self::default() + Self::default() + } + + #[allow(clippy::unused_self)] + #[cfg_attr(not(windows), allow(clippy::unnecessary_wraps))] + pub fn devices(&self, input: bool) -> Result { + #[cfg(windows)] + if self.sender.is_some() { + return self.request(|reply| Command::Devices { input, reply }); + } + let _ = input; + Ok(serde_json::json!([])) + } + + #[allow(clippy::unused_self)] + pub fn select(&self, input: bool, id: Option<&str>) -> Result<(), String> { + #[cfg(windows)] + if self.sender.is_some() { + self.request(|reply| Command::Select { + input, + id: id.map(str::to_owned), + reply, + })?; + return Ok(()); } + let _ = (input, id); + Err("native audio device selection is unavailable on this host".into()) + } + + #[cfg(windows)] + fn request( + &self, + command: impl FnOnce(std::sync::mpsc::Sender>) -> Command, + ) -> Result { + let (reply, response) = std::sync::mpsc::channel(); + self.sender + .as_ref() + .ok_or("audio worker unavailable")? + .try_send(command(reply)) + .map_err(|e| format!("audio worker unavailable or busy: {e}"))?; + response + .recv() + .map_err(|e| format!("audio worker stopped: {e}"))? } - #[cfg_attr( - not(windows), - allow( - clippy::unused_self, - reason = "the Windows audio backend retains instance state; other hosts consume samples without output" - ) - )] + + #[allow(clippy::unused_self)] pub fn submit(&self, samples: Vec) { #[cfg(windows)] if let Some(sender) = &self.sender { - let _ = sender.try_send(samples); + let _ = sender.try_send(Command::Samples(samples)); } #[cfg(not(windows))] drop(samples); } } + +#[cfg(all(windows, not(test)))] +mod native { + use super::Command; + use rodio::cpal::{ + Device, Host, + traits::{DeviceTrait, HostTrait}, + }; + use std::collections::BTreeMap; + + struct NativeAudio { + host: Host, + output: Option<(rodio::OutputStream, rodio::Sink)>, + input: Option, + outputs: BTreeMap, + inputs: BTreeMap, + serial: u64, + } + + fn open(device: Device) -> Result<(rodio::OutputStream, rodio::Sink), String> { + let stream = rodio::OutputStreamBuilder::from_device(device) + .map_err(|e| e.to_string())? + .open_stream() + .map_err(|e| e.to_string())?; + let sink = rodio::Sink::connect_new(stream.mixer()); + Ok((stream, sink)) + } + + impl NativeAudio { + fn new() -> Self { + let host = rodio::cpal::default_host(); + let output = host.default_output_device().and_then(|d| open(d).ok()); + Self { + host, + output, + input: None, + outputs: BTreeMap::new(), + inputs: BTreeMap::new(), + serial: 0, + } + } + + fn devices(&mut self, input: bool) -> Result { + let devices = if input { + self.host.input_devices() + } else { + self.host.output_devices() + } + .map_err(|e| e.to_string())?; + let mut cache = BTreeMap::new(); + let mut list = Vec::new(); + for device in devices { + let name = device.name().map_err(|e| e.to_string())?; + self.serial += 1; + let direction = if input { "input" } else { "output" }; + let id = format!("{direction}:{}", self.serial); + list.push(serde_json::json!({"id":id,"name":name})); + cache.insert(id, device); + } + if input { + self.inputs = cache; + } else { + self.outputs = cache; + } + Ok(serde_json::json!(list)) + } + + fn select(&mut self, input: bool, id: Option<&str>) -> Result { + let device = if let Some(id) = id { + let cache = if input { &self.inputs } else { &self.outputs }; + cache + .get(id) + .cloned() + .ok_or("unknown audio device ID; list devices again")? + } else { + (if input { + self.host.default_input_device() + } else { + self.host.default_output_device() + }) + .ok_or("no default audio device available")? + }; + if input { + device.default_input_config().map_err(|e| e.to_string())?; + self.input = Some(device); + } else { + // Open first: failure preserves the current stream and voices. + let replacement = open(device)?; + self.output = Some(replacement); + } + Ok(serde_json::Value::Null) + } + + fn submit(&self, samples: Vec) { + if let Some((_, sink)) = &self.output + && !samples.is_empty() + && sink.len() < 4 + { + sink.append(rodio::buffer::SamplesBuffer::new(2, 48_000, samples)); + } + } + } + + pub(super) fn run(receiver: &std::sync::mpsc::Receiver) { + let mut audio = NativeAudio::new(); + while let Ok(command) = receiver.recv() { + match command { + Command::Samples(samples) => audio.submit(samples), + Command::Devices { input, reply } => { + let _ = reply.send(audio.devices(input)); + } + Command::Select { input, id, reply } => { + let _ = reply.send(audio.select(input, id.as_deref())); + } + } + } + } +} diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index c0d2263d..9ef47dc5 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -1096,6 +1096,27 @@ impl GameplayHost for RuntimeHost { } if op.starts_with("audio_") { let mut context = self.gameplay.lock().map_err(|_| "gameplay lock poisoned")?; + match op { + "audio_get_output_devices" | "audio_get_input_devices" => { + return context + .audio_output + .devices(op == "audio_get_input_devices"); + } + "audio_set_output_device" | "audio_set_input_device" => { + let device = query.get("device").filter(|v| !v.is_null()); + let id = device + .map(|v| { + v.as_str() + .ok_or("audio device must be an ID string or null") + }) + .transpose()?; + context + .audio_output + .select(op == "audio_set_input_device", id)?; + return Ok(serde_json::Value::Null); + } + _ => {} + } let owner = Owner { entity_bound: self.entity_bound, scene: context.scene, @@ -2528,6 +2549,33 @@ mod tests { } } + #[test] + fn audio_device_queries_validate_ids_and_report_headless_hosts() { + let world = Arc::new(Mutex::new(SceneWorld::new())); + let mut host = environment_host(&world); + for direction in ["input", "output"] { + assert_eq!( + host.gameplay_query( + serde_json::json!({"op":format!("audio_get_{direction}_devices")}) + ) + .unwrap(), + serde_json::json!([]) + ); + for invalid in [ + serde_json::json!(42), + serde_json::json!({"id":"device"}), + serde_json::json!([]), + ] { + let error = host.gameplay_query(serde_json::json!({"op":format!("audio_set_{direction}_device"),"device":invalid})).unwrap_err(); + assert!(error.contains("ID string or null"), "{error}"); + } + for id in [serde_json::Value::Null, serde_json::json!("missing")] { + let error = host.gameplay_query(serde_json::json!({"op":format!("audio_set_{direction}_device"),"device":id})).unwrap_err(); + assert!(error.contains("unavailable"), "{error}"); + } + } + } + #[test] fn environment_patches_validate_atomically_and_preserve_other_settings() { let world = Arc::new(Mutex::new(SceneWorld::new())); diff --git a/Engine/crates/engine-scripting/src/cpp_sdk.rs b/Engine/crates/engine-scripting/src/cpp_sdk.rs index 75ec0aa8..e9475104 100644 --- a/Engine/crates/engine-scripting/src/cpp_sdk.rs +++ b/Engine/crates/engine-scripting/src/cpp_sdk.rs @@ -271,6 +271,10 @@ inline auto followSpeed(const std::string&e,const Curve&path,double speed,const } namespace Timer{inline auto every(double interval,std::function fn,std::optional count={}){if(interval<=0||(count&&*count<1))throw std::invalid_argument("positive interval/count required");auto token=Gameplay::callback([fn=std::move(fn)](auto&){fn();});return Gameplay::start(rustic_detail::object({{"kind","sequence"},{"actions",RusticValue::Array{rustic_detail::object({{"kind","wait"},{"duration",interval}}),rustic_detail::object({{"kind","callback"},{"token",token}})}}}),!count,count?*count-1:0);}} namespace Audio{ +inline RusticValue get_output_devices(){return rustic.query(rustic_detail::object({{"op","audio_get_output_devices"}}));} +inline RusticValue get_input_devices(){return rustic.query(rustic_detail::object({{"op","audio_get_input_devices"}}));} +inline void set_output_device(const RusticValue& device=nullptr){rustic.query(rustic_detail::object({{"op","audio_set_output_device"},{"device",device}}));} +inline void set_input_device(const RusticValue& device=nullptr){rustic.query(rustic_detail::object({{"op","audio_set_input_device"},{"device",device}}));} struct Voice{std::string id;void stop(){rustic.query(rustic_detail::object({{"op","audio_stop"},{"entity",id}}));}void pause(){rustic.query(rustic_detail::object({{"op","audio_pause"},{"entity",id}}));}void resume(){rustic.query(rustic_detail::object({{"op","audio_resume"},{"entity",id}}));}}; inline Voice play(const std::string&source,double volume=1,double pitch=1,bool loop=false){return {rustic.query(rustic_detail::object({{"op","audio_play"},{"source",source},{"volume",volume},{"pitch",pitch},{"loop",loop}})).string()};} inline Voice playAt(const std::string&source,RusticVector3 position,double volume=1,double pitch=1,bool loop=false){return {rustic.query(rustic_detail::object({{"op","audio_play"},{"source",source},{"position",Physics::vec(position)},{"volume",volume},{"pitch",pitch},{"loop",loop}})).string()};} diff --git a/Engine/crates/engine-scripting/src/external_runtime.rs b/Engine/crates/engine-scripting/src/external_runtime.rs index d671bbbd..8e3ee083 100644 --- a/Engine/crates/engine-scripting/src/external_runtime.rs +++ b/Engine/crates/engine-scripting/src/external_runtime.rs @@ -1208,6 +1208,90 @@ mod tests { Err("not declared".into()) } fn set_enabled(&mut self, _enabled: bool) {} + fn gameplay_query(&mut self, query: Value) -> Result { + match query["op"].as_str().unwrap_or("") { + "audio_get_output_devices" => Ok(json!([ + {"id":"output:1","name":"USB \"speaker\" \\ 雪"}, + {"id":"output:2","name":"USB \"speaker\" \\ 雪"} + ])), + "audio_get_input_devices" => Ok(json!([])), + "audio_set_output_device" => { + if !query["device"].is_null() && query["device"] != "output:1" { + return Err("unknown output device".into()); + } + self.translation[0] += 1.0; + Ok(Value::Null) + } + "audio_set_input_device" => { + if !query["device"].is_null() { + return Err("unknown input device".into()); + } + self.translation[0] += 1.0; + Ok(Value::Null) + } + _ => Err("unsupported gameplay query".into()), + } + } + } + + #[test] + fn audio_device_sdks_preserve_records_across_queries_and_select_defaults() { + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + (ScriptLanguage::Python, "devices.py", br"from rustic import Audio, run +def on_start(): + outputs = Audio.get_output_devices() + assert len(outputs) == 2 and outputs[0]['name'] == outputs[1]['name'] + assert Audio.get_input_devices() == [] + Audio.set_output_device(outputs[0]['id']) + Audio.set_output_device(None) + Audio.set_input_device(None) +run(globals()) +"), + (ScriptLanguage::C, "devices.c", br#"#include "rustic.h" +#include +void start(void){ + RusticAudioDevices outputs=Audio.get_output_devices(); + assert(outputs.count==2 && strcmp(outputs.items[0].name,outputs.items[1].name)==0); + assert(Audio.get_input_devices().count==0); + assert(strcmp(outputs.items[0].name,"USB \"speaker\" \\ \xe9\x9b\xaa")==0); + Audio.set_output_device(outputs.items[0].id); + Audio.set_output_device(NULL); + Audio.set_input_device(NULL); +} +int main(void){return rustic_run((RusticBehavior){.on_start=start});}"#), + (ScriptLanguage::Cpp, "devices.cpp", br#"#include "rustic.hpp" +#include +void start(){ + auto outputs=Audio::get_output_devices(); + assert(outputs.array().size()==2 && outputs.array()[0].at("name").string()==outputs.array()[1].at("name").string()); + assert(Audio::get_input_devices().array().empty()); + Audio::set_output_device(outputs.array()[0].at("id")); + Audio::set_output_device(); + Audio::set_input_device(); +} +int main(){return rustic_run(RusticBehavior{.on_start=start});}"#), + ]; + for (language, name, source) in fixtures { + if !probe_language_toolchain(*language).available { + continue; + } + let mut behavior = ExternalBehavior::load( + ScriptId::new(), + *language, + source, + name, + Box::new(Host { + entity: EntityId::new(), + translation: [0.0; 3], + }), + ) + .unwrap(); + behavior.on_start().unwrap(); + assert_eq!( + behavior.host().lock().unwrap().translation(), + [3.0, 0.0, 0.0] + ); + } } #[test] diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.cs b/Engine/crates/engine-scripting/src/sdk/Rustic.cs index 65125f42..bdf12f58 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.cs +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.cs @@ -213,6 +213,10 @@ public record Options(double speed=1,double blendIn=0,double blendOut=0,string e public static Gameplay.Handle recoil(string e,double[] rotation,double duration,string easing="Linear")=>Gameplay.Start(Tween.Action(e,"Rotation",rotation,duration,easing),false,1,true); } public static class Audio{ + public static JsonElement get_output_devices()=>Query(new{op="audio_get_output_devices"}); + public static JsonElement get_input_devices()=>Query(new{op="audio_get_input_devices"}); + public static void set_output_device(string? device=null)=>Query(new{op="audio_set_output_device",device}); + public static void set_input_device(string? device=null)=>Query(new{op="audio_set_input_device",device}); public sealed record Voice(string Id){public void stop()=>Query(new{op="audio_stop",entity=Id});public void pause()=>Query(new{op="audio_pause",entity=Id});public void resume()=>Query(new{op="audio_resume",entity=Id});} public static Voice play(string source,double volume=1,double pitch=1,bool loop=false)=>new(Query(new{op="audio_play",source,volume,pitch,loop}).GetString()!); public static Voice playAt(string source,double[] position,double volume=1,double pitch=1,bool loop=false)=>new(Query(new{op="audio_play",source,position,volume,pitch,loop}).GetString()!); diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.java b/Engine/crates/engine-scripting/src/sdk/Rustic.java index fe483986..ee33c7e7 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.java +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.java @@ -219,6 +219,10 @@ record Options(double speed,double blendIn,double blendOut,String easing,String static Gameplay.Handle recoil(String e,double[] rotation,double duration,String ease){return Gameplay.start(Tween.action(e,"Rotation",rotation,duration,ease),false,1,true);} } static class Audio{ + static Object get_output_devices(){return query(args("audio_get_output_devices"));} + static Object get_input_devices(){return query(args("audio_get_input_devices"));} + static void set_output_device(String device){query(args("audio_set_output_device","device",device));} + static void set_input_device(String device){query(args("audio_set_input_device","device",device));} record Voice(String id){void stop(){query(args("audio_stop","entity",id));}void pause(){query(args("audio_pause","entity",id));}void resume(){query(args("audio_resume","entity",id));}} static Voice play(String source,double volume,double pitch,boolean loop){return new Voice((String)query(args("audio_play","source",source,"volume",volume,"pitch",pitch,"loop",loop)));} static Voice playAt(String source,double[] position,double volume,double pitch,boolean loop){return new Voice((String)query(args("audio_play","source",source,"position",position,"volume",volume,"pitch",pitch,"loop",loop)));} diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.js b/Engine/crates/engine-scripting/src/sdk/gameplay.js index 1b74cb4b..01c0c2e5 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.js +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.js @@ -100,6 +100,10 @@ Animation.ik=(root,middle,tip,target,opts={})=>start(rustic.query({op:'animation_ik',root:entity(root),middle:entity(middle),tip:entity(tip),target,pole:opts.pole,duration:opts.duration||0,weight:opts.weight==null?1:opts.weight,easing:opts.easing||'Linear'})); Animation.footPlacement=Animation.ik;Animation.lookAt=Movement.lookAt;Animation.headTracking=Movement.lookAt;Animation.recoil=(o,r,t,e)=>start(tween(o,'Rotation',r,t,e),{repeats:1,pingPong:true}); const Audio={play:(source,opts={})=>{const id=rustic.query({...opts,op:'audio_play',source});return {id,stop:()=>rustic.query({op:'audio_stop',entity:id}),pause:()=>rustic.query({op:'audio_pause',entity:id}),resume:()=>rustic.query({op:'audio_resume',entity:id})};}, + get_output_devices:()=>rustic.query({op:'audio_get_output_devices'}), + get_input_devices:()=>rustic.query({op:'audio_get_input_devices'}), + set_output_device:(device=null)=>rustic.query({op:'audio_set_output_device',device}), + set_input_device:(device=null)=>rustic.query({op:'audio_set_input_device',device}), playAt:(source,position,opts={})=>Audio.play(source,{...opts,position}),volume:(v,value)=>rustic.query({op:'audio_volume',entity:entity(v),value}),pitch:(v,value)=>rustic.query({op:'audio_pitch',entity:entity(v),value}), fadeIn:(v,t,e,volume=1)=>start(tween(v,'Volume',volume,t,e,0)),fadeOut:(v,t,e)=>Tween.to(v,'Volume',0,t,e), crossfade:(a,b,t,e)=>start({kind:'parallel',actions:[tween(a,'Volume',0,t,e),tween(b,'Volume',1,t,e,0)]})};globalThis.Audio=Audio; diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.lua b/Engine/crates/engine-scripting/src/sdk/gameplay.lua index 2d268954..c9f2f03d 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.lua +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.lua @@ -266,6 +266,10 @@ function __rustic_audio_object(source) object.unload=object.Unload return object end +function Audio.get_output_devices() return rustic.query({op='audio_get_output_devices'}) end +function Audio.get_input_devices() return rustic.query({op='audio_get_input_devices'}) end +function Audio.set_output_device(device) return rustic.query({op='audio_set_output_device',device=device}) end +function Audio.set_input_device(device) return rustic.query({op='audio_set_input_device',device=device}) end function Audio.load(source) return __rustic_audio_object(source):load() end function Audio.play(source,opts) opts=opts or {};opts.op='audio_play';opts.source=source;local id=rustic.query(opts) diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.h b/Engine/crates/engine-scripting/src/sdk/rustic.h index 84cb6149..e7c4b05d 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.h +++ b/Engine/crates/engine-scripting/src/sdk/rustic.h @@ -15,6 +15,8 @@ typedef struct { RusticType type; bool boolean; int64_t integer; double number; typedef struct { bool pressed,released,held; double axis; } RusticActionState; typedef struct { const char *key,*state; bool repeat; } RusticKeyEvent; typedef struct { size_t count; const char **items; } RusticList; +typedef struct { const char *id,*name; } RusticAudioDevice; +typedef struct { size_t count; RusticAudioDevice *items; } RusticAudioDevices; typedef struct { size_t count; RusticKeyEvent *items; } RusticKeyEvents; typedef struct { void (*on_create)(void), (*on_start)(void), (*on_enable)(void), (*on_disable)(void), (*on_destroy)(void), (*on_stop)(void); @@ -157,15 +159,15 @@ static void r_environment_sky(const char *path); static const struct {struct {RusticValue (*getEnvironment)(const char*);void (*setEnvironment)(const char*,RusticValue);void (*setSkyTexture)(const char*);const char *(*Find)(const char*);RusticList (*List)(const char*);} scene;void (*setCurrentCamera)(const char*);} Game={{r_environment_get,r_environment_set,r_environment_sky,r_find,r_list},r_camera}; /* Queries preserve the lifecycle snapshot while flushing earlier writes in order. */ -typedef struct {char *source,*pending;RToken *tokens;size_t count,at;} RQuerySave; +typedef struct {char *source,*pending;RToken *tokens;size_t count,at;bool audio_devices;} RQuerySave; typedef struct {bool hit;char entity[64];RusticVector3 point,normal;double distance;} RusticHit; typedef struct {double value,velocity;} RusticDamping; typedef struct {bool found;char status[16],error[256];} RusticOperationState; -typedef struct {RusticOperationState operation;RusticValue value;RusticHit hit;RusticDamping damping;char *json;RusticList list;} RQueryResult; +typedef struct {RusticOperationState operation;RusticValue value;RusticHit hit;RusticDamping damping;char *json;RusticList list;RusticAudioDevices devices;} RQueryResult; static char *r_query_owned[8192];static size_t r_query_owned_count; static char *r_owned(const char *s,size_t length){if(r_query_owned_count>=8192)r_fail("Rustic C: query allocation budget");char *copy=(char*)malloc(length+1);if(!copy)r_fail("Rustic C: allocation failed");if(s)memcpy(copy,s,length);else memset(copy,0,length);copy[length]=0;r_query_owned[r_query_owned_count++]=copy;return copy;} static RQuerySave r_query_begin(const char *op){ - RQuerySave saved={0};saved.source=(char*)malloc(strlen(r_source)+1);saved.pending=(char*)malloc(r_written+1);saved.tokens=(RToken*)malloc(r_count*sizeof(RToken)); + RQuerySave saved={0};saved.audio_devices=strcmp(op,"audio_get_output_devices")==0||strcmp(op,"audio_get_input_devices")==0;saved.source=(char*)malloc(strlen(r_source)+1);saved.pending=(char*)malloc(r_written+1);saved.tokens=(RToken*)malloc(r_count*sizeof(RToken)); if(!saved.source||!saved.pending||!saved.tokens)r_fail("Rustic C: query snapshot allocation failed"); strcpy(saved.source,r_source);memcpy(saved.pending,r_output,r_written+1);memcpy(saved.tokens,r_tokens,r_count*sizeof(RToken));saved.count=r_count;saved.at=r_at; r_written=0;r_output[0]=0;r_append("{\"op\":");r_quote(op);return saved; @@ -188,6 +190,15 @@ static RQueryResult r_query_end(RQuerySave saved){ size_t entity=r_field(result,"entity");if(entityvolume:1);r_q_number("pitch",opts?opts->pitch:1);r_append(",\"loop\":");r_append(opts&&opts->loop?"true":"false");if(spatial)r_q_value("position",r_vec(position));RQueryResult result=r_query_end(s);RusticAudioVoice voice={{0}};if(result.value.type!=RUSTIC_STRING)r_fail("Rustic C: audio host returned an invalid voice");snprintf(voice.id,sizeof voice.id,"%s",result.value.string);return voice;} static RusticAudioVoice r_audio_play(const char *source,const RusticAudioOptions *opts){return r_audio_play_at(source,(RusticVector3){0},false,opts);} static RusticAudioVoice r_audio_at(const char *source,RusticVector3 position,const RusticAudioOptions *opts){return r_audio_play_at(source,position,true,opts);} @@ -382,7 +397,7 @@ static void r_clock_scale(double value){RQuerySave s=r_query_begin("clock");r_q_ static void r_clock_pause(void){RQuerySave s=r_query_begin("clock");r_append(",\"paused\":true");(void)r_query_end(s);} static void r_clock_resume(void){RQuerySave s=r_query_begin("clock");r_append(",\"paused\":false");(void)r_query_end(s);} static const struct{void(*timeScale)(double),(*pause)(void),(*resume)(void);} Clock={r_clock_scale,r_clock_pause,r_clock_resume}; -static const struct {RusticAudioVoice(*play)(const char*,const RusticAudioOptions*),(*playAt)(const char*,RusticVector3,const RusticAudioOptions*);void(*stop)(RusticAudioVoice),(*pause)(RusticAudioVoice),(*resume)(RusticAudioVoice),(*volume)(RusticAudioVoice,double),(*pitch)(RusticAudioVoice,double);RusticGameplayHandle(*fadeIn)(RusticAudioVoice,double,const char*,void(*)(void)),(*fadeOut)(RusticAudioVoice,double,const char*,void(*)(void)),(*crossfade)(RusticAudioVoice,RusticAudioVoice,double,const char*,void(*)(void));} Audio={r_audio_play,r_audio_at,r_audio_stop,r_audio_pause,r_audio_resume,r_audio_volume,r_audio_pitch,r_audio_fade_in,r_audio_fade_out,r_audio_crossfade}; +static const struct {RusticAudioVoice(*play)(const char*,const RusticAudioOptions*),(*playAt)(const char*,RusticVector3,const RusticAudioOptions*);void(*stop)(RusticAudioVoice),(*pause)(RusticAudioVoice),(*resume)(RusticAudioVoice),(*volume)(RusticAudioVoice,double),(*pitch)(RusticAudioVoice,double);RusticGameplayHandle(*fadeIn)(RusticAudioVoice,double,const char*,void(*)(void)),(*fadeOut)(RusticAudioVoice,double,const char*,void(*)(void)),(*crossfade)(RusticAudioVoice,RusticAudioVoice,double,const char*,void(*)(void));RusticAudioDevices(*get_output_devices)(void),(*get_input_devices)(void);void(*set_output_device)(const char*),(*set_input_device)(const char*);} Audio={r_audio_play,r_audio_at,r_audio_stop,r_audio_pause,r_audio_resume,r_audio_volume,r_audio_pitch,r_audio_fade_in,r_audio_fade_out,r_audio_crossfade,r_audio_get_output_devices,r_audio_get_input_devices,r_audio_set_output_device,r_audio_set_input_device}; static const struct {void(*addMarker)(const char*,const char*,double,const char*);RusticGameplayHandle(*value)(const RusticKeyframe*,size_t,void(*)(RusticValue),void(*)(void));RusticAnimationOptions(*defaults)(void);RusticList(*load)(const char*,const char*),(*clips)(const char*);void(*registerClip)(const char*,RusticAnimationClip);RusticGameplayHandle(*play)(const char*,const char*,const RusticAnimationOptions*,void(*)(void));void(*onMarker)(RusticGameplayHandle,const char*,void(*)(void)),(*stop)(RusticGameplayHandle),(*pause)(RusticGameplayHandle),(*speed)(RusticGameplayHandle,double),(*loop)(RusticGameplayHandle,bool);RusticGameplayHandle(*blend)(const char*,const char*,const char*,double,const char*,void(*)(void)),(*transition)(const char*,const char*,const char*,double,const char*,void(*)(void)),(*ik)(const char*,const char*,const char*,RusticVector3,RusticVector3,double,double,const char*,void(*)(void)),(*footPlacement)(const char*,const char*,const char*,RusticVector3,RusticVector3,double,double,const char*,void(*)(void)),(*lookAt)(const char*,RusticVector3,double,const char*,void(*)(void)),(*headTracking)(const char*,RusticVector3,double,const char*,void(*)(void)),(*recoil)(const char*,RusticValue,double,const char*,void(*)(void));} Animation={r_animation_add_marker,r_animation_value,r_animation_defaults,r_animation_load,r_animation_refs,r_animation_register,r_animation_play,r_animation_marker,r_gameplay_cancel,r_gameplay_pause,r_gameplay_speed,r_gameplay_loop,r_animation_blend,r_animation_blend,r_animation_ik,r_animation_ik,r_look_at,r_look_at,r_recoil}; static const struct {RusticConnection(*on)(const char*,void(*)(const RusticValue*,size_t)),(*once)(const char*,void(*)(const RusticValue*,size_t)),(*connect)(const char*,const char*,void(*)(const RusticValue*,size_t),bool);void(*emit)(const char*,const RusticValue*,size_t,const char*),(*disconnect)(RusticConnection);} Events={r_events_on,r_events_once,r_events_connect,r_events_emit,r_events_disconnect}; typedef struct {double duration,speed;bool useSpeed;const char *easing;} RusticMotionOptions; diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.php b/Engine/crates/engine-scripting/src/sdk/rustic.php index 615c30d2..6729b7b6 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.php +++ b/Engine/crates/engine-scripting/src/sdk/rustic.php @@ -245,6 +245,10 @@ final class AudioVoice{ function __construct(public string $id){}function stop():void{global $rustic;$rustic->query(["op"=>"audio_stop","entity"=>$this->id]);}function pause():void{global $rustic;$rustic->query(["op"=>"audio_pause","entity"=>$this->id]);}function resume():void{global $rustic;$rustic->query(["op"=>"audio_resume","entity"=>$this->id]);} } final class Audio{ + static function get_output_devices():array{global $rustic;return $rustic->query(["op"=>"audio_get_output_devices"]);} + static function get_input_devices():array{global $rustic;return $rustic->query(["op"=>"audio_get_input_devices"]);} + static function set_output_device(?string $device=null):void{global $rustic;$rustic->query(["op"=>"audio_set_output_device","device"=>$device]);} + static function set_input_device(?string $device=null):void{global $rustic;$rustic->query(["op"=>"audio_set_input_device","device"=>$device]);} static function play(string $source,array $opts=[]):AudioVoice{global $rustic;return new AudioVoice($rustic->query(["op"=>"audio_play","source"=>$source]+$opts));} static function playAt(string $source,array $position,array $opts=[]):AudioVoice{return self::play($source,["position"=>$position]+$opts);} static function volume(AudioVoice $v,float $value):void{global $rustic;$rustic->query(["op"=>"audio_volume","entity"=>$v->id,"value"=>$value]);}static function pitch(AudioVoice $v,float $value):void{global $rustic;$rustic->query(["op"=>"audio_pitch","entity"=>$v->id,"value"=>$value]);} diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.py b/Engine/crates/engine-scripting/src/sdk/rustic.py index 1916e23e..42f13606 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.py +++ b/Engine/crates/engine-scripting/src/sdk/rustic.py @@ -311,6 +311,14 @@ def stop(self): return rustic.query(dict(op="audio_stop",entity=self.id)) def pause(self): return rustic.query(dict(op="audio_pause",entity=self.id)) def resume(self): return rustic.query(dict(op="audio_resume",entity=self.id)) class Audio: + @staticmethod + def get_output_devices(): return rustic.query(dict(op="audio_get_output_devices")) + @staticmethod + def get_input_devices(): return rustic.query(dict(op="audio_get_input_devices")) + @staticmethod + def set_output_device(device=None): return rustic.query(dict(op="audio_set_output_device",device=device)) + @staticmethod + def set_input_device(device=None): return rustic.query(dict(op="audio_set_input_device",device=device)) @staticmethod def play(source,options=None): return AudioVoice(rustic.query(dict(options or {},op="audio_play",source=source))) @staticmethod diff --git a/Engine/docs/Scripting/API/audio.md b/Engine/docs/Scripting/API/audio.md index 702b2ef6..db48cec8 100644 --- a/Engine/docs/Scripting/API/audio.md +++ b/Engine/docs/Scripting/API/audio.md @@ -91,8 +91,70 @@ The object methods and `Audio.load` are available in Lua 5.4 and Luau. Existing `Audio.play(source, options?)` and `Audio.playAt` in all languages still decode and play immediately; their voice controls continue to work. +## Choose audio devices + +The four device methods are available on `Audio` in every supported script +language (including HTML inline JavaScript). Native enumeration and output +selection currently work in the Windows player. Other platforms return an empty +list and report an unavailable-backend error when selecting a device. + +- `Audio.get_output_devices()` lists playback devices. +- `Audio.get_input_devices()` lists microphone/input devices. +- Each entry contains an opaque string `id` and a display `name`. Names can be + duplicated; use the ID to select a device. +- `Audio.set_output_device(id)` switches this scene's mixed audio to that output. +- `Audio.set_input_device(id)` validates and retains the scene's selected input + device. **Microphone recording/capture is not implemented**, so this call does + not produce microphone samples or route microphone sound to playback. +- Pass `null` (Lua/Luau `nil`, Python `None`, C `NULL`) to either setter to select + the current system default. Java requires an explicit `null` argument; other + bindings except C also allow omitting the argument. Setters return no value. +- Lists are snapshots. IDs belong to the current Play scene and the latest list + for that direction; listing that direction again invalidates older IDs. Do + not save IDs in project settings. Selection is shared by scripts in the scene + and resets on Stop/new Play. Output and input IDs are not interchangeable. +- Output switching retains mixer voices and their cursors but discards samples + queued on the previous device; a brief interruption is possible. A failed + switch keeps the previous output. Device removal is not automatically recovered; + list again and choose another device or the default. + +Create and attach a JavaScript Object Component script to an enabled scene +object as described above. Press **Play** on Windows: + +```javascript +globalThis.behavior = { + Start() { + const outputs = Audio.get_output_devices(); + if (outputs.length > 0) { + Audio.set_output_device(outputs[0].id); + } + const inputs = Audio.get_input_devices(); + if (inputs.length > 0) { + Audio.set_input_device(inputs[0].id); + } + } +}; +``` + +The scene uses the first listed output, and retains the first input when one is +available. Play an existing audio asset to hear the selected output. To restore +system output, call `Audio.set_output_device(null)` from a callback. + +Dynamic bindings return arrays/lists/tables of device records. C returns +`RusticAudioDevices` with `count` and `items[i].id` / `items[i].name`; these values +last until the next callback. C++ returns a `RusticValue` array (`.array()`); +C# returns a `JsonElement` array (`EnumerateArray()`); Java returns a JSON list +of maps; PHP returns an array of associative arrays. + +Unknown/stale IDs, IDs from the other direction, unavailable devices, missing +system defaults and native driver failures raise a script error. A non-string +argument also raises an error. Check the Console; list devices again after +plugging/unplugging hardware. Empty lists are valid on hosts without devices. + ## Calls +- `Audio.get_output_devices() / get_input_devices()` +- `Audio.set_output_device(id = null) / set_input_device(id = null)` - `Audio.load(source)` (Lua/Luau; returns a reusable audio handle) - `Audio.play(source, options?)` - `Audio.playAt(source, position, options?)` @@ -129,7 +191,7 @@ globalThis.behavior = { ## Behavior and limitations - Provide assets/music.ogg before Play. The voice fades in over two seconds, starts fading out at five seconds and stops at six. -- Windows player output uses the default audio device. Other platforms currently mix PCM headlessly, so successful playback may produce no device sound. +- Windows player output starts on the default audio device; `Audio.set_output_device` can change it. Other platforms currently mix PCM headlessly, so successful playback may produce no device sound. - FadeOut reaches zero volume but does not stop the voice; stop explicitly or allow natural completion. Crossfade fades the old voice to zero and the new voice from zero to one. - PlayAt attenuates distance relative to the active camera. The mixer renders 48 kHz stereo PCM with linear resampling for pitch. - Clock scaling changes source playback rate; Clock.pause and voice.pause retain their source cursors. Natural completion releases non-looping voice PCM; loaded audio handles retain PCM until unload or owner cleanup. Stopping cancels voice-targeting actions on the next scheduler tick.