Skip to main content

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);