engine/
mixer.rs

1// SPDX-FileCopyrightText: 2025 Jens Pitkänen <jens.pitkanen@helsinki.fi>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5use core::cmp::Reverse;
6
7use platform::{thread_pool::ThreadPool, Instant, Platform, AUDIO_CHANNELS, AUDIO_SAMPLE_RATE};
8
9use crate::{
10    allocators::LinearAllocator,
11    collections::FixedVec,
12    multithreading::parallelize,
13    resources::{
14        audio_clip::AudioClipHandle, ResourceDatabase, ResourceLoader, AUDIO_SAMPLES_PER_CHUNK,
15    },
16};
17
18#[derive(Debug)]
19struct PlayingClip {
20    channel: usize,
21    clip: AudioClipHandle,
22    start_position: u64,
23}
24
25impl PlayingClip {
26    fn get_end(&self, resources: &ResourceDatabase) -> u64 {
27        self.start_position + resources.get_audio_clip(self.clip).samples as u64
28    }
29}
30
31/// Audio modulation settings that affect all sounds played on a specific
32/// channel.
33#[derive(Debug)]
34pub struct ChannelSettings {
35    /// The volume of the audio, from 0 (muted) to 255 (played raw).
36    pub volume: u8,
37}
38
39/// Holds currently playing audio tracks and their playback parameters.
40pub struct Mixer {
41    playing_clips: FixedVec<'static, PlayingClip>,
42    /// Configurable settings for the channels where audio clips are played.
43    pub channels: FixedVec<'static, ChannelSettings>,
44    playback_buffer: FixedVec<'static, [i16; AUDIO_CHANNELS]>,
45    /// The audio position where new sounds should start playing, updated at the
46    /// start of each frame with [`Mixer::update_audio_sync`].
47    playback_position: u64,
48}
49
50impl Mixer {
51    /// Creates a new [`Mixer`] with the specified amount of channels, a cap for
52    /// how many sounds can play at the same time, and a buffer length (in
53    /// samples), returning None if the allocator doesn't have enough memory.
54    ///
55    /// Each channel has its own set of controllable parameters, for e.g. tuning
56    /// the volume between music and sound effects separately.
57    ///
58    /// The playback buffer's length should be at least as long as the
59    /// platform's audio buffer, plus how many samples would be played back
60    /// during one frame, to avoid choppy audio. A longer length will help with
61    /// avoiding audio cutting out in case of lagspikes, at the cost of taking
62    /// up more memory and slowing down [`Mixer::render_audio`]. This buffer
63    /// length does not affect latency.
64    pub fn new(
65        arena: &'static LinearAllocator,
66        channel_count: usize,
67        max_playing_clips: usize,
68        playback_buffer_length: usize,
69    ) -> Option<Mixer> {
70        let mut playback_buffer = FixedVec::new(arena, playback_buffer_length)?;
71        playback_buffer.fill_with_zeroes();
72
73        let playing_clips = FixedVec::new(arena, max_playing_clips)?;
74
75        let mut channels = FixedVec::new(arena, channel_count)?;
76        for _ in 0..channel_count {
77            channels.push(ChannelSettings { volume: 0xFF }).unwrap();
78        }
79
80        Some(Mixer {
81            playing_clips,
82            channels,
83            playback_buffer,
84            playback_position: 0,
85        })
86    }
87
88    /// Plays the audio clip starting this frame, returning false if the sound
89    /// can't be played.
90    ///
91    /// If the mixer is already playing the maximum amount of concurrent clips,
92    /// and `important` is `true`, the clip with the least playback time left
93    /// will be replaced with this sound. Note that this may cause popping audio
94    /// artifacts, though on the other hand, with many other sounds playing, it
95    /// may not be as noticeable. If `important` is `false`, this sound will not
96    /// be played.
97    ///
98    /// If the channel index is out of bounds, the sound will not be played.
99    pub fn play_clip(
100        &mut self,
101        channel: usize,
102        clip: AudioClipHandle,
103        important: bool,
104        resources: &ResourceDatabase,
105    ) -> bool {
106        if channel >= self.channels.len() {
107            return false;
108        }
109
110        let playing_clip = PlayingClip {
111            channel,
112            clip,
113            start_position: self.playback_position,
114        };
115
116        if !self.playing_clips.is_full() {
117            self.playing_clips.push(playing_clip).unwrap();
118        } else if important {
119            if self.playing_clips.is_empty() {
120                return false; // both full and empty, can't play anything
121            }
122
123            let mut lowest_end_time = self.playing_clips[0].get_end(resources);
124            let mut candidate_index = 0;
125            for (i, clip) in self.playing_clips.iter().enumerate().skip(1) {
126                let end_time = clip.get_end(resources);
127                if end_time < lowest_end_time {
128                    lowest_end_time = end_time;
129                    candidate_index = i;
130                }
131            }
132
133            self.playing_clips[candidate_index] = playing_clip;
134        } else {
135            return false;
136        }
137
138        true
139    }
140
141    /// Synchronizes the mixer's internal clock with the platform's audio
142    /// buffer.
143    ///
144    /// Should be called at the start of the frame by the engine.
145    pub fn update_audio_sync(&mut self, frame_timestamp: Instant, platform: &dyn Platform) {
146        let (playback_position, playback_timestamp) = platform.audio_playback_position();
147        if let Some(time_since_playback_pos) = frame_timestamp.duration_since(playback_timestamp) {
148            let frame_offset_from_playback_pos =
149                time_since_playback_pos.as_micros() * AUDIO_SAMPLE_RATE as u128 / 1_000_000;
150            self.playback_position = playback_position + frame_offset_from_playback_pos as u64;
151        } else {
152            self.playback_position = playback_position;
153        }
154    }
155
156    /// Mixes the currently playing tracks together and updates the platform's
157    /// audio buffer with the result.
158    ///
159    /// Should be called at the end of the frame by the engine.
160    pub fn render_audio(
161        &mut self,
162        thread_pool: &mut ThreadPool,
163        platform: &dyn Platform,
164        resources: &ResourceDatabase,
165        resource_loader: &mut ResourceLoader,
166    ) {
167        profiling::function_scope!();
168        // Remove clips that have played to the end
169        self.playing_clips
170            .sort_unstable_by_key(|clip| Reverse(clip.get_end(resources)));
171        if let Some(finished_clips_start_index) = (self.playing_clips)
172            .iter()
173            .position(|clip| clip.get_end(resources) < self.playback_position)
174        {
175            self.playing_clips.truncate(finished_clips_start_index);
176        }
177
178        // Render
179        parallelize(
180            thread_pool,
181            &mut self.playback_buffer,
182            |playback_buffer, offset| {
183                profiling::scope!("mix audio");
184                playback_buffer.fill([0; AUDIO_CHANNELS]);
185                let playback_start = self.playback_position + offset as u64;
186                for clip in &*self.playing_clips {
187                    let volume = self.channels[clip.channel].volume;
188                    let asset = resources.get_audio_clip(clip.clip);
189
190                    let already_played = playback_start.saturating_sub(clip.start_position) as u32;
191                    let first_chunk =
192                        asset.chunks.start + already_played / AUDIO_SAMPLES_PER_CHUNK as u32;
193                    let last_chunk =
194                        asset.chunks.start + asset.samples / AUDIO_SAMPLES_PER_CHUNK as u32;
195
196                    let mut playback_offset =
197                        clip.start_position.saturating_sub(playback_start) as usize;
198                    for chunk_index in first_chunk..=last_chunk {
199                        if playback_buffer.len() <= playback_offset {
200                            break;
201                        }
202
203                        let chunk_start =
204                            (chunk_index - asset.chunks.start) * AUDIO_SAMPLES_PER_CHUNK as u32;
205                        let chunk_end =
206                            (chunk_index - asset.chunks.start + 1) * AUDIO_SAMPLES_PER_CHUNK as u32;
207
208                        if let Some(chunk) = &resources.chunks.get(chunk_index) {
209                            let chunk_samples =
210                                bytemuck::cast_slice::<u8, [i16; AUDIO_CHANNELS]>(&chunk.0);
211                            let first_sample_idx =
212                                (already_played.max(chunk_start) - chunk_start) as usize;
213                            let last_sample_idx =
214                                (asset.samples.min(chunk_end).saturating_sub(chunk_start)) as usize;
215                            if first_sample_idx < last_sample_idx {
216                                render_audio_chunk(
217                                    &chunk_samples[first_sample_idx..last_sample_idx],
218                                    &mut playback_buffer[playback_offset..],
219                                    volume,
220                                );
221                                playback_offset += last_sample_idx - first_sample_idx;
222                            }
223                        } else {
224                            break;
225                        }
226                    }
227                }
228            },
229        );
230
231        // Send the rendered audio to be played back
232        platform.update_audio_buffer(self.playback_position, &self.playback_buffer);
233
234        // Queue up any missing audio chunks in preparation for the next frame
235        for clip in &*self.playing_clips {
236            let asset = resources.get_audio_clip(clip.clip);
237            let current_pos = self.playback_position.saturating_sub(clip.start_position);
238            let current_chunk_index = (current_pos / AUDIO_SAMPLES_PER_CHUNK as u64) as u32;
239            let next_chunk_index = current_chunk_index + 1;
240
241            resource_loader.queue_chunk(asset.chunks.start + current_chunk_index, resources);
242            if asset.chunks.start + next_chunk_index < asset.chunks.end {
243                resource_loader.queue_chunk(asset.chunks.start + next_chunk_index, resources);
244            }
245        }
246    }
247}
248
249fn render_audio_chunk(
250    chunk_samples: &[[i16; AUDIO_CHANNELS]],
251    dst: &mut [[i16; AUDIO_CHANNELS]],
252    volume: u8,
253) {
254    profiling::function_scope!();
255    for (dst, sample) in dst.iter_mut().zip(chunk_samples) {
256        for channel in 0..AUDIO_CHANNELS {
257            let sample = sample[channel];
258            let attenuated = ((sample as i32 * volume as i32) / u8::MAX as i32) as i16;
259            dst[channel] += attenuated;
260        }
261    }
262}