helena 0.1.0

Core types and component interfaces for helena, a latent data-to-waveform generation platform.
Documentation
//! Streaming synthesis — the hard constraint (PRD NFR-004), as a contract.
//!
//! The product's promise is a causal decoder whose latent is a live control
//! surface in the 20–60 ms morph band. This module gives that promise a
//! vocabulary: a [`StreamSpec`] of constructor-fixed facts, a
//! [`StreamingDecoder`] that mints per-stream [`StreamSession`]s, and — in
//! [`crate::laws`] — the bit-exactness law every implementation must
//! instantiate: block-wise streaming output is *exactly* the offline decode.
//!
//! Sessions are a typestate: opened fresh, pushed in order, and consumed by
//! `finish` — a drained session is unusable by move semantics. Sessions are
//! minted by the decoder they serve; implementations brand their internal
//! state to the minting instance so a session cannot be replayed against a
//! topologically identical twin (the silent-desync class).

use crate::error::Result;
use crate::latent::{LatentBlock, LatentKind};
use crate::seams::AudioDecoder;
use crate::signal::PcmBlock;
use crate::time::{Frames, TimeBase};

/// Constructor-fixed streaming facts, derived from the causal plan
/// ([`crate::plan`]) before any weight exists.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StreamSpec {
    block: Frames,
    lookahead: Frames,
    time_base: TimeBase,
}

impl StreamSpec {
    /// Describe a stream consuming `block` latent frames per step with
    /// `lookahead` frames of future context (`0` for a strictly causal
    /// decoder), on the given clock.
    ///
    /// Returns an error if `block` is zero — a stream that consumes nothing
    /// per step is not a stream.
    pub fn new(block: Frames, lookahead: Frames, time_base: TimeBase) -> Result<Self> {
        if block.get() == 0 {
            return Err(crate::error::Error::validation(
                "stream block size must be nonzero",
            ));
        }
        Ok(Self {
            block,
            lookahead,
            time_base,
        })
    }

    /// Latent frames consumed per [`StreamSession::push`].
    pub fn block(&self) -> Frames {
        self.block
    }

    /// Future context the decoder needs before emitting; `0` means strictly
    /// causal.
    pub fn lookahead(&self) -> Frames {
        self.lookahead
    }

    /// The latent clock the stream ticks on.
    pub fn time_base(&self) -> TimeBase {
        self.time_base
    }

    /// Worst-case algorithmic latency in seconds: the time to accumulate one
    /// block plus the lookahead, on this clock. Compute cost is measured, not
    /// promised, so it is deliberately absent.
    pub fn latency_seconds(&self) -> f64 {
        self.time_base.seconds(Frames(
            self.block.get().saturating_add(self.lookahead.get()),
        ))
    }
}

/// A decoder that can synthesize incrementally, block by block.
///
/// The offline path ([`AudioDecoder::decode`]) and the streaming path are
/// two views of one function: the streaming law
/// ([`crate::laws::streaming_matches_offline`]) requires their outputs to be
/// bit-identical. `spec()` is a constructor-fixed fact, never a runtime
/// negotiation.
pub trait StreamingDecoder<K: LatentKind>: AudioDecoder<K> {
    /// Per-stream decode state, minted fresh by [`open`](Self::open).
    type Session: StreamSession<K>;

    /// The stream's fixed facts (block size, lookahead, clock).
    fn spec(&self) -> StreamSpec;

    /// Open a fresh stream. Each session owns its own history; sessions are
    /// never shared, reused across streams, or valid for another decoder
    /// instance.
    fn open(&self) -> Self::Session;
}

/// One live stream: push blocks in order, then drain by value.
///
/// `finish(self)` consumes the session — pushing after the drain is a type
/// error, not a runtime state check.
pub trait StreamSession<K: LatentKind> {
    /// Push the next `spec().block` latent frames; returns the audio emitted
    /// for this step.
    ///
    /// Implementations reject (with an error, not a panic) a block whose
    /// frame count differs from the spec.
    fn push(&mut self, block: &LatentBlock<K>) -> Result<PcmBlock>;

    /// Drain the tail (emitted-but-unflushed audio) and close the stream.
    /// The tail may be empty for a strictly causal decoder.
    fn finish(self) -> Result<PcmBlock>;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::time::SampleRate;
    use std::num::NonZeroU32;

    fn tb() -> TimeBase {
        TimeBase::new(
            SampleRate::try_from(48_000).unwrap(),
            NonZeroU32::new(480).unwrap(),
        )
    }

    #[test]
    fn spec_rejects_zero_block() {
        assert!(StreamSpec::new(Frames(0), Frames(0), tb()).is_err());
        assert!(StreamSpec::new(Frames(1), Frames(0), tb()).is_ok());
    }

    #[test]
    fn latency_is_block_plus_lookahead_on_the_clock() {
        // 480-sample stride at 48 kHz: 10 ms per frame.
        let spec = StreamSpec::new(Frames(4), Frames(2), tb()).unwrap();
        assert!((spec.latency_seconds() - 0.060).abs() < 1e-12);
        // A strictly causal 3-frame block sits at 30 ms — inside the
        // 20–60 ms morph band (PRD §12.1).
        let causal = StreamSpec::new(Frames(3), Frames(0), tb()).unwrap();
        assert!((causal.latency_seconds() - 0.030).abs() < 1e-12);
    }
}