Skip to main content

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}