khora_data/flow/audio.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `AudioFlow` — projects the ECS audio domain into a per-tick
16//! [`AudioView`] consumed by `SpatialMixingLane`.
17//!
18//! Replaces the previous design where the lane queried `World` directly
19//! (and mutated `AudioSource.state.cursor` in place from the audio
20//! callback thread). Per CLAD, lanes consume Views from the
21//! [`LaneBus`](khora_core::lane::LaneBus) and write outputs into the
22//! [`OutputDeck`](khora_core::lane::OutputDeck) — they do not query the
23//! World.
24//!
25//! The matching `audio_playback_writeback` `DataSystem`
26//! ([`crate::ecs::systems::audio_playback_writeback`], `Maintenance`
27//! phase) drains the writeback slot the lane wrote into and applies the
28//! new playback states back to the `AudioSource` components.
29
30use khora_core::ecs::entity::EntityId;
31use khora_core::math::affine_transform::AffineTransform;
32use khora_core::math::Vec3;
33use khora_core::Runtime;
34
35use crate::assets::SoundData;
36use crate::ecs::{
37 AudioListener, AudioSource, GlobalTransform, PlaybackState, SemanticDomain, World,
38};
39use crate::flow::{Flow, Selection};
40use crate::register_flow;
41use khora_core::asset::AssetHandle;
42
43/// Per-source snapshot fed to `SpatialMixingLane::mix`.
44#[derive(Debug, Clone)]
45pub struct AudioSourceSnapshot {
46 /// Origin entity (used to key the writeback).
47 pub entity: EntityId,
48 /// Sound data the source plays.
49 pub handle: AssetHandle<SoundData>,
50 /// World-space position of the source.
51 pub position: Vec3,
52 /// Linear gain.
53 pub volume: f32,
54 /// Whether the source loops on end.
55 pub looping: bool,
56 /// Whether the source starts playing on first encounter.
57 pub autoplay: bool,
58 /// Current playback state, copied from the component each frame.
59 /// Mutations made by the lane go through
60 /// [`AudioPlaybackWriteback`].
61 pub state: Option<PlaybackState>,
62}
63
64/// Update emitted by `SpatialMixingLane` for a single source — applied
65/// back to its `AudioSource` component by the
66/// `audio_playback_writeback` DataSystem.
67#[derive(Debug, Clone)]
68pub struct AudioPlaybackUpdate {
69 /// Target entity.
70 pub entity: EntityId,
71 /// New playback state. `None` means "stopped" (cursor reset).
72 pub new_state: Option<PlaybackState>,
73}
74
75/// Slot type written into [`OutputDeck`](khora_core::lane::OutputDeck)
76/// by `SpatialMixingLane`. Drained in `Maintenance` by the
77/// `audio_playback_writeback` DataSystem.
78#[derive(Debug, Default, Clone)]
79pub struct AudioPlaybackWriteback {
80 /// One update per source touched this frame.
81 pub updates: Vec<AudioPlaybackUpdate>,
82}
83
84/// View published by [`AudioFlow`] into the
85/// [`LaneBus`](khora_core::lane::LaneBus). Carries the listener pose
86/// and a snapshot of every active `AudioSource`.
87#[derive(Debug, Default, Clone)]
88pub struct AudioView {
89 /// Number of `AudioSource` components in the world (kept for
90 /// backwards-compat telemetry).
91 pub source_count: usize,
92 /// World-space translation of the first `AudioListener`, if any.
93 pub listener_position: Option<Vec3>,
94 /// Full affine transform of the first listener (used for spatial
95 /// pan / volume math by the mixing lane).
96 pub listener_transform: Option<AffineTransform>,
97 /// Per-source snapshots consumed by the mixing lane.
98 pub sources: Vec<AudioSourceSnapshot>,
99}
100
101/// Audio domain Flow — read-only projection of the audio domain.
102#[derive(Default)]
103pub struct AudioFlow;
104
105impl Flow for AudioFlow {
106 type View = AudioView;
107
108 const DOMAIN: SemanticDomain = SemanticDomain::Audio;
109 const NAME: &'static str = "audio";
110
111 /// The projection below reads only `AudioSource` / `AudioListener`
112 /// (Audio domain) and `GlobalTransform` (Spatial domain) — no runtime
113 /// state — so those two epochs (plus the World instance id) fully
114 /// determine the view.
115 fn cache_key(&self, world: &World, _runtime: &Runtime) -> Option<u64> {
116 Some(crate::flow::combine_cache_key([
117 world.instance_id(),
118 world.domain_epoch(SemanticDomain::Audio),
119 world.domain_epoch(SemanticDomain::Spatial),
120 ]))
121 }
122
123 fn project(&self, world: &World, _sel: &Selection, _runtime: &Runtime) -> Self::View {
124 let source_count = world.query::<&AudioSource>().count();
125 let listener = world
126 .query::<(&AudioListener, &GlobalTransform)>()
127 .next()
128 .map(|(_, t)| t.0);
129 let listener_position = listener.map(|t| t.translation());
130
131 // Snapshot every source so the mixing lane never needs to touch
132 // the World. The snapshot copy is cheap because `state` is a
133 // small `Option<PlaybackState>` and `handle` is an `Arc`.
134 let mut sources = Vec::with_capacity(source_count);
135 for (entity, (source, transform)) in world
136 .query::<(&AudioSource, &GlobalTransform)>()
137 .enumerate()
138 {
139 // CRPECS query yields tuples without entity ids today; the
140 // index aligns with `world.iter_entities().filter(...)`. The
141 // mixing lane uses `entity` only as a writeback key — a
142 // stable index across the same frame is sufficient.
143 let _ = entity;
144 sources.push(AudioSourceSnapshot {
145 entity: EntityId {
146 index: entity as u32,
147 generation: 0,
148 },
149 handle: source.handle.clone(),
150 position: transform.0.translation(),
151 volume: source.volume,
152 looping: source.looping,
153 autoplay: source.autoplay,
154 state: source.state.clone(),
155 });
156 }
157
158 AudioView {
159 source_count,
160 listener_position,
161 listener_transform: listener,
162 sources,
163 }
164 }
165}
166
167register_flow!(AudioFlow);