tympan_apo/apo.rs
1//! Top-level Audio Processing Object surface.
2//!
3//! Users of the framework implement [`ProcessingObject`] for a
4//! type carrying the per-instance state of their APO. The
5//! framework's COM harness will (in a follow-up PR) construct an
6//! instance via [`ProcessingObject::new`], drive the lifecycle, and
7//! forward audio buffers into [`ProcessingObject::process`].
8
9use crate::buffer::BufferFlags;
10use crate::clsid::Clsid;
11use crate::error::HResult;
12use crate::format::{Format, FormatNegotiation};
13use crate::realtime::RealtimeContext;
14
15/// Per-buffer input handed to [`ProcessingObject::process`].
16///
17/// Borrows an interleaved float32 sample buffer from the host and
18/// carries the [`BufferFlags`] the host stamped on it. Both
19/// fields are accessed through const fns so the wrapper is
20/// allocation-free and realtime-safe.
21#[derive(Copy, Clone, Debug)]
22pub struct ProcessInput<'a> {
23 samples: &'a [f32],
24 flags: BufferFlags,
25}
26
27impl<'a> ProcessInput<'a> {
28 /// Wrap a sample slice and the host's flag word.
29 ///
30 /// The framework's COM harness will construct one of these per
31 /// `APOProcess` invocation; tests construct them directly.
32 #[inline]
33 #[must_use]
34 pub const fn new(samples: &'a [f32], flags: BufferFlags) -> Self {
35 Self { samples, flags }
36 }
37
38 /// Interleaved float32 samples — `frame_count * channel_count`
39 /// elements long.
40 #[inline]
41 #[must_use]
42 pub const fn samples(&self) -> &'a [f32] {
43 self.samples
44 }
45
46 /// Flags the host stamped on this buffer.
47 #[inline]
48 #[must_use]
49 pub const fn flags(&self) -> BufferFlags {
50 self.flags
51 }
52
53 /// Convenience: `true` iff [`Self::flags`] is
54 /// [`BufferFlags::SILENT`].
55 #[inline]
56 #[must_use]
57 pub const fn is_silent(&self) -> bool {
58 self.flags.is_silent()
59 }
60}
61
62/// On/off state of a [`SystemEffect`].
63///
64/// Mirrors the Windows `AUDIO_SYSTEMEFFECT_STATE` enumeration:
65/// `Off = 0`, `On = 1`. The framework converts between the two
66/// representations at the COM boundary so the user-facing API
67/// stays cross-platform.
68#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
69pub enum SystemEffectState {
70 /// Effect is currently off; the user-side `process` may skip
71 /// the per-effect work.
72 Off,
73 /// Effect is currently on; the user-side `process` should
74 /// apply the effect normally.
75 #[default]
76 On,
77}
78
79/// One system effect this APO advertises to the audio engine via
80/// `IAudioSystemEffects2::GetEffectsList` and (when this APO
81/// supports per-effect toggling) `IAudioSystemEffects3::GetControllableSystemEffectsList`.
82///
83/// The Windows audio engine surfaces these in the Sound Settings
84/// UI; users can see the effect by name (resolved via the
85/// per-effect ID in the audio property store) and — for effects
86/// marked `controllable` — toggle each independently.
87///
88/// The framework's default `ProcessingObject::system_effects`
89/// returns an empty slice, so an APO advertises no enumerable
90/// effects unless it overrides the method. That matches the
91/// historical behaviour of a v1-only `IAudioSystemEffects` marker.
92#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
93pub struct SystemEffect {
94 /// Unique identifier for this effect within the APO. The audio
95 /// engine pairs this with the friendly name in the per-endpoint
96 /// `AudioSystemEffects_PropertyStore` to render the UI.
97 pub id: Clsid,
98 /// `true` if the audio engine may call
99 /// `SetAudioSystemEffectState` on this effect at runtime.
100 /// `false` means the effect is always on and the Sound Settings
101 /// UI hides the toggle.
102 pub controllable: bool,
103 /// Initial state of the effect, also surfaced through
104 /// `GetControllableSystemEffectsList`.
105 pub state: SystemEffectState,
106}
107
108impl SystemEffect {
109 /// Construct an effect descriptor from its unique ID. Defaults
110 /// to non-controllable, `On` state — the v1/v2 behaviour where
111 /// effects are always-on markers in the discovery list.
112 #[inline]
113 #[must_use]
114 pub const fn new(id: Clsid) -> Self {
115 Self {
116 id,
117 controllable: false,
118 state: SystemEffectState::On,
119 }
120 }
121
122 /// Builder-style: mark this effect as user-controllable.
123 #[inline]
124 #[must_use]
125 pub const fn with_controllable(mut self, controllable: bool) -> Self {
126 self.controllable = controllable;
127 self
128 }
129
130 /// Builder-style: set the initial state.
131 #[inline]
132 #[must_use]
133 pub const fn with_state(mut self, state: SystemEffectState) -> Self {
134 self.state = state;
135 self
136 }
137}
138
139/// Category of an Audio Processing Object, as exposed via
140/// `IAudioSystemEffects` / `IAudioSystemEffects3`.
141///
142/// The audio engine instantiates an APO once per (endpoint, mode)
143/// combination, and the category selects where in the per-stream
144/// processing graph the APO sits.
145///
146/// - [`Self::Sfx`] — Stream Effect: per-application processing, runs
147/// before the engine mixes streams together. Used for
148/// per-application volume, ducking, or stream-specific effects.
149/// - [`Self::Mfx`] — Mode Effect: per-endpoint, per-mode processing,
150/// applied to the mixed stream for a specific audio mode
151/// (Communications, Media, etc.).
152/// - [`Self::Efx`] — Endpoint Effect: applied to the entire endpoint
153/// regardless of mode. Inherently device-wide; ship with care.
154#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
155#[non_exhaustive]
156pub enum ApoCategory {
157 /// Stream effect — per-application processing.
158 Sfx,
159 /// Mode effect — per-endpoint, per-mode processing.
160 Mfx,
161 /// Endpoint effect — per-endpoint, mode-agnostic processing.
162 Efx,
163}
164
165/// User-implemented Audio Processing Object.
166///
167/// Each implementor represents one CLSID-identified APO with a
168/// distinct name, category, and processing behaviour. The
169/// framework's COM harness instantiates the type via
170/// [`Self::new`], drives the format-negotiation /
171/// `LockForProcess` / `APOProcess` / `UnlockForProcess` sequence,
172/// and routes the audio engine's calls into the corresponding
173/// trait methods.
174///
175/// ## Default format negotiation
176///
177/// The default [`Self::is_input_format_supported`] /
178/// [`Self::is_output_format_supported`] implementations accept any
179/// IEEE-float32 stream and suggest a float32 alternative for
180/// anything else. This matches the canonical Windows audio engine
181/// negotiation and is the format the [`Self::process`] callback's
182/// `&[f32]` / `&mut [f32]` parameters assume.
183///
184/// Implementors that want to handle integer PCM or other formats
185/// directly should override these methods and use [`Format`]'s
186/// accessors to do their own typed slicing inside `process`.
187///
188/// ## Realtime safety
189///
190/// [`Self::process`] takes a [`RealtimeContext`] reference. Any
191/// helper function callable from `process` should also accept
192/// `&RealtimeContext`, which makes its presence in the call stack
193/// visible at compile time. The realtime path must be
194/// allocation-free and lock-free per `CLAUDE.md` prohibitions 1
195/// and 2.
196pub trait ProcessingObject: Sized + Send {
197 /// CLSID under which the audio engine and `regsvr32` identify
198 /// this APO. Must be unique per implementor.
199 const CLSID: Clsid;
200
201 /// Human-readable APO name. Surfaced in `Sound Settings` and
202 /// elsewhere in the Windows audio UI.
203 const NAME: &'static str;
204
205 /// Copyright notice carried in the registered class metadata.
206 const COPYRIGHT: &'static str;
207
208 /// Category controlling where in the per-stream processing
209 /// graph the APO sits — see [`ApoCategory`].
210 const CATEGORY: ApoCategory;
211
212 /// Construct a fresh APO instance.
213 ///
214 /// Called by the framework's class factory once per
215 /// `CoCreateInstance` invocation from the audio engine. Heap
216 /// allocation is allowed here; it is *not* allowed inside
217 /// [`Self::process`].
218 fn new() -> Self;
219
220 /// Decide whether `format` is acceptable as an input format.
221 ///
222 /// The default implementation accepts any IEEE-float32 stream
223 /// and suggests `pcm_float32(format.sample_rate(),
224 /// format.channels())` otherwise.
225 fn is_input_format_supported(&self, format: &Format) -> FormatNegotiation {
226 default_float32_negotiation(format)
227 }
228
229 /// Decide whether `format` is acceptable as an output format.
230 ///
231 /// The default implementation mirrors
232 /// [`Self::is_input_format_supported`].
233 fn is_output_format_supported(&self, format: &Format) -> FormatNegotiation {
234 default_float32_negotiation(format)
235 }
236
237 /// List of system effects this APO advertises to the audio
238 /// engine via `IAudioSystemEffects2::GetEffectsList`.
239 ///
240 /// The default returns an empty slice — the APO is registered
241 /// but exposes no granular effects in the Sound Settings UI.
242 /// Implementors that want per-effect toggles should override
243 /// this with a slice borrowed from `&self`.
244 ///
245 /// Called from non-realtime threads; allocation is permitted
246 /// in implementations that need it (though the default's
247 /// constant slice is allocation-free).
248 fn system_effects(&self) -> &[SystemEffect] {
249 &[]
250 }
251
252 /// Toggle the state of one of this APO's advertised effects.
253 ///
254 /// Called by the audio engine through
255 /// `IAudioSystemEffects3::SetAudioSystemEffectState` whenever
256 /// the user flips an effect toggle in the Sound Settings UI.
257 /// The framework dispatches into this method only for effects
258 /// the user advertised with `controllable: true`; if `id` does
259 /// not match any advertised effect the framework returns
260 /// `E_INVALIDARG` and does not invoke the method.
261 ///
262 /// The default implementation is a no-op. Implementors that
263 /// want to react to state changes (skip processing when off,
264 /// reload internal state, etc.) override this method.
265 ///
266 /// Called from a non-realtime thread and may race with the
267 /// realtime `process` callback. Implementors that read effect
268 /// state from `process` should mediate via atomics or a
269 /// realtime-safe primitive.
270 fn set_system_effect_state(&mut self, id: &Clsid, state: SystemEffectState) {
271 let _ = (id, state);
272 }
273
274 /// Prepare for processing under the supplied input/output
275 /// formats.
276 ///
277 /// Called once between `Initialize` and the first
278 /// [`Self::process`] invocation. This is where implementors
279 /// should pre-allocate internal buffers; allocation in
280 /// [`Self::process`] is prohibited.
281 ///
282 /// Returning an [`HResult`] failure aborts lock and surfaces
283 /// to the audio engine as an `IsInitialized=FALSE` state.
284 fn lock_for_process(&mut self, input: &Format, output: &Format) -> Result<(), HResult> {
285 let _ = (input, output);
286 Ok(())
287 }
288
289 /// Release any resources acquired during
290 /// [`Self::lock_for_process`].
291 ///
292 /// Always paired with a prior successful `lock_for_process`.
293 /// Allocator use is allowed.
294 fn unlock_for_process(&mut self) {}
295
296 /// Process one audio buffer.
297 ///
298 /// Realtime-critical: must be allocation-free, lock-free, and
299 /// must not call into the kernel. Reachable callees should
300 /// take `&RealtimeContext` to make the constraint visible
301 /// throughout the call graph.
302 ///
303 /// `input` carries the host's input samples and the
304 /// [`BufferFlags`] the host stamped on the buffer (the APO is
305 /// free to short-circuit when [`ProcessInput::is_silent`] is
306 /// `true`). `output` is the interleaved float32 buffer to
307 /// write into; the same length as `input.samples()` (the
308 /// framework enforces this before dispatching).
309 ///
310 /// The return value becomes the `u32BufferFlags` field of the
311 /// host's output `APO_CONNECTION_PROPERTY` — typically
312 /// [`BufferFlags::VALID`] for normal audio, or
313 /// [`BufferFlags::SILENT`] when the APO knows it wrote pure
314 /// silence and the engine may skip downstream work.
315 fn process(
316 &mut self,
317 rt: &RealtimeContext,
318 input: ProcessInput<'_>,
319 output: &mut [f32],
320 ) -> BufferFlags;
321}
322
323#[inline]
324fn default_float32_negotiation(format: &Format) -> FormatNegotiation {
325 if format.is_float() && format.bits_per_sample() == 32 {
326 FormatNegotiation::Accept
327 } else {
328 FormatNegotiation::Suggest(Format::pcm_float32(format.sample_rate(), format.channels()))
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 /// Reference implementor used by the trait's unit tests.
337 /// Copies input straight to output frame-by-frame.
338 struct Passthrough;
339
340 impl ProcessingObject for Passthrough {
341 const CLSID: Clsid = Clsid::from_u128(0xCAFEBABE_DEAD_BEEF_1234_56789ABCDEF0);
342 const NAME: &'static str = "tympan-apo passthrough";
343 const COPYRIGHT: &'static str = "test fixture";
344 const CATEGORY: ApoCategory = ApoCategory::Sfx;
345
346 fn new() -> Self {
347 Self
348 }
349
350 fn process(
351 &mut self,
352 _rt: &RealtimeContext,
353 input: ProcessInput<'_>,
354 output: &mut [f32],
355 ) -> BufferFlags {
356 output.copy_from_slice(input.samples());
357 input.flags()
358 }
359 }
360
361 #[test]
362 fn variants_are_distinct() {
363 assert_ne!(ApoCategory::Sfx, ApoCategory::Mfx);
364 assert_ne!(ApoCategory::Mfx, ApoCategory::Efx);
365 assert_ne!(ApoCategory::Sfx, ApoCategory::Efx);
366 }
367
368 #[test]
369 fn associated_constants_round_trip() {
370 assert_eq!(Passthrough::NAME, "tympan-apo passthrough");
371 assert_eq!(Passthrough::COPYRIGHT, "test fixture");
372 assert_eq!(Passthrough::CATEGORY, ApoCategory::Sfx);
373 assert!(!Passthrough::CLSID.is_nil());
374 }
375
376 #[test]
377 fn default_input_format_accepts_float32_at_any_rate_channels() {
378 let apo = Passthrough::new();
379 for (rate, ch) in [(48_000, 1), (44_100, 2), (96_000, 6), (192_000, 8)] {
380 assert_eq!(
381 apo.is_input_format_supported(&Format::pcm_float32(rate, ch)),
382 FormatNegotiation::Accept,
383 "float32 {rate} Hz × {ch} ch must be accepted",
384 );
385 }
386 }
387
388 #[test]
389 fn default_input_format_suggests_float32_for_int_pcm() {
390 let apo = Passthrough::new();
391 for fmt in [
392 Format::pcm_int16(48_000, 2),
393 Format::pcm_int24(44_100, 1),
394 Format::pcm_int32(96_000, 4),
395 ] {
396 match apo.is_input_format_supported(&fmt) {
397 FormatNegotiation::Suggest(suggested) => {
398 assert!(suggested.is_float(), "suggestion must be float");
399 assert_eq!(suggested.bits_per_sample(), 32);
400 assert_eq!(suggested.sample_rate(), fmt.sample_rate());
401 assert_eq!(suggested.channels(), fmt.channels());
402 }
403 other => panic!("expected Suggest for {fmt:?}, got {other:?}"),
404 }
405 }
406 }
407
408 #[test]
409 fn default_input_format_suggests_float32_for_float64() {
410 // Even float-but-wrong-width formats must be steered to
411 // float32.
412 let apo = Passthrough::new();
413 let f = Format::pcm_float64(48_000, 1);
414 match apo.is_input_format_supported(&f) {
415 FormatNegotiation::Suggest(s) => {
416 assert!(s.is_float());
417 assert_eq!(s.bits_per_sample(), 32);
418 }
419 other => panic!("expected Suggest, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn default_output_negotiation_matches_input() {
425 let apo = Passthrough::new();
426 for fmt in [
427 Format::pcm_float32(48_000, 1),
428 Format::pcm_int16(44_100, 2),
429 Format::pcm_float64(96_000, 6),
430 ] {
431 assert_eq!(
432 apo.is_input_format_supported(&fmt),
433 apo.is_output_format_supported(&fmt),
434 );
435 }
436 }
437
438 #[test]
439 fn default_lock_for_process_succeeds() {
440 let mut apo = Passthrough::new();
441 let fmt = Format::pcm_float32(48_000, 1);
442 assert!(apo.lock_for_process(&fmt, &fmt).is_ok());
443 }
444
445 #[test]
446 fn default_unlock_is_callable() {
447 let mut apo = Passthrough::new();
448 apo.unlock_for_process();
449 }
450
451 #[test]
452 fn process_runs_against_a_synthetic_buffer() {
453 // The realtime witness can be constructed in tests via
454 // the crate-private `new_unchecked` constructor; this is
455 // the only path that bypasses the contract, and it is
456 // permitted here because the test exercises pure logic,
457 // not realtime-thread-dependent behaviour.
458 let mut apo = Passthrough::new();
459 let samples = [0.1_f32, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8];
460 let mut output = [0.0_f32; 8];
461 let rt = unsafe { RealtimeContext::new_unchecked() };
462 let out_flags = apo.process(
463 &rt,
464 ProcessInput::new(&samples, BufferFlags::VALID),
465 &mut output,
466 );
467 assert_eq!(output, samples);
468 assert_eq!(out_flags, BufferFlags::VALID);
469 }
470
471 #[test]
472 fn process_input_exposes_samples_and_flags() {
473 let samples = [1.0_f32, 2.0, 3.0];
474 let input = ProcessInput::new(&samples, BufferFlags::SILENT);
475 assert_eq!(input.samples(), &samples);
476 assert_eq!(input.flags(), BufferFlags::SILENT);
477 assert!(input.is_silent());
478 }
479
480 #[test]
481 fn process_input_is_not_silent_when_flag_is_valid() {
482 let samples = [0.0_f32];
483 let input = ProcessInput::new(&samples, BufferFlags::VALID);
484 assert!(!input.is_silent());
485 }
486
487 #[test]
488 fn process_passes_through_input_flags() {
489 // Passthrough's implementation returns the input flags
490 // verbatim — verify each variant survives the round-trip.
491 let mut apo = Passthrough::new();
492 let rt = unsafe { RealtimeContext::new_unchecked() };
493 let samples = [0.5_f32; 4];
494 for f in [
495 BufferFlags::VALID,
496 BufferFlags::SILENT,
497 BufferFlags::INVALID,
498 ] {
499 let mut output = [0.0_f32; 4];
500 let out = apo.process(&rt, ProcessInput::new(&samples, f), &mut output);
501 assert_eq!(out, f);
502 }
503 }
504}