Skip to content

Recycle frame payload buffers from core back to the group audio threads #68

Closed

Description

@kahrendt

Problem

Each encoded frame and each visualizer cell carries its payload in a std::vector<uint8_t> that the group audio threads allocate for that frame, and core frees when its frame ring drops it. In steady state this is an allocation per frame on the audio threads and a free per frame on core. Reusing the buffers needs a way to hand them from core back to the audio threads, and no channel runs that way today: the group-to-core FIFO goes one way and the pipeline-set slot carries configuration only. Split from #23, which reused the other per-frame buffers.

Where

  • The payload:
    std::vector<uint8_t> payload;
  • A frame per pipeline cell, its payload filled by the encoder:
    bool GroupAudio::emit(Instance& instance, const JobView& view, size_t samples) {
    sched::Frame frame;
    frame.epoch = this->epoch_;
    frame.index = instance.next;
    frame.ts_us = sched::frame_timestamp(view.anchor, instance.next);
    // An Opus packet decodes to a whole frame (spec player/v1.md "Codec framing"), a drained
    // epoch's last one padded with silence
    frame.samples = static_cast<uint32_t>(
    instance.format.codec == sched::Codec::OPUS ? instance.frame_samples : samples);
    frame.duration_us = sched::frame_duration_us(frame.samples, instance.format.sample_rate);
    // With a lead, a drained epoch's last frames take what pending still holds
    const size_t bytes = std::min(samples * instance.sample_bytes, instance.pending.size());
    if (!instance.encoder->encode_quantized(std::span(instance.pending.data(), bytes),
    frame.payload)) {
    return false;
    }
    this->fifo_.push(FrameReady{instance.id, std::move(frame)});
    instance.pending.erase(instance.pending.begin(),
    instance.pending.begin() + static_cast<std::ptrdiff_t>(bytes));
    instance.last = instance.next;
    instance.next += instance.cells;
    return true;
  • A frame per visualizer cell, its payload reserved per cell:
    sched::Frame frame;
    frame.epoch = this->epoch_;
    frame.index = cell;
    frame.ts_us = cell_start + shift;
    frame.payload.reserve(analysis.cell_bytes);
    const auto instant = [&](int64_t at, bool is_periodic, bool onset) {
    // A drained epoch's last cell has no input past the drain position to analyze
    const uint64_t position = sched::time_position(anchor, at);
    if (position > end) {
    return true;
    }
    if (!this->feed_analysis(analysis, view, position)) {
    return false;
    }
    const size_t before = frame.payload.size();
    analysis.analysis->analyze(at + shift, is_periodic, onset, frame.payload);
    if (frame.payload.size() != before) {
    frame.ts_us = at + shift;
    }
    return true;
    };
    const bool merged = periodic && *periodic == cell_start;
    if (!instant(cell_start, merged, true) ||
    (periodic && !merged && !instant(*periodic, true, false)) ||
    !this->feed_analysis(analysis, view, end)) {
    // Input below the head is staged, so only a failure, or a read the view refused, stops it
    // here: the cell starts again with no history
    analysis.begun = false;
    analysis.start = cell;
    return analysis.failed;
    }
    this->fifo_.push(FrameReady{analysis.id, std::move(frame)});
    analysis.last = cell;
    analysis.next = cell + 1;
    return true;
  • The one-way FIFO and the slot:
    class GroupToCoreFifo {
    struct State;
    public:
    /// The most events one handler delivers before it posts itself again
    static constexpr size_t DRAIN_BUDGET = 64;
    /// The events the FIFO and a handler's batch have room for before they grow: a few jobs' frames
    /// and epoch events between two drains
    static constexpr size_t QUEUED_EVENTS = 16;
    /// @brief The pushing end, which a job holds on its thread and which may outlive the FIFO
    class Producer {
    public:
    /// @brief Any thread. Once the FIFO is destroyed, the event is dropped.
    void push(GroupEvent event) const;
    private:
    friend class GroupToCoreFifo;
    Producer(EventLoop& loop, std::shared_ptr<State> state);
    /// Outlives every producer: the group audio threads are joined before the loop goes
    EventLoop* loop_;
    std::shared_ptr<State> state_;
    };
    /// @param deliver called on core, in push order
    GroupToCoreFifo(EventLoop& loop, std::function<void(GroupEvent&)> deliver);
    /// @brief A posted handler still pending delivers nothing, and a later push is dropped. On
    /// core.
    ~GroupToCoreFifo();
    GroupToCoreFifo(const GroupToCoreFifo&) = delete;
    GroupToCoreFifo& operator=(const GroupToCoreFifo&) = delete;
    Producer producer() const;
    private:
    struct State {
    std::mutex mutex;
    /// Its producers bound it (above); room for QUEUED_EVENTS at first
    Ring<GroupEvent> events{QUEUED_EVENTS, Ring<GroupEvent>::UNLIMITED,
    MemoryLocation::PREFER_EXTERNAL};
    bool drain_posted{false};
    /// The FIFO is gone
    bool closed{false};
    /// Core only
    std::function<void(GroupEvent&)> deliver;
    /// Core only: the batch a handler delivers
    Ring<GroupEvent> taking{QUEUED_EVENTS, DRAIN_BUDGET, MemoryLocation::PREFER_EXTERNAL};
    };
    static void drain(EventLoop& loop, const std::shared_ptr<State>& state);
    EventLoop& loop_;
    std::shared_ptr<State> state_;
    };
    // ==============================================================================
    // Pipeline-set slot
    // ==============================================================================
    /// @brief One pipeline instance core wants
    struct PipelineRequest {
    sched::InstanceId instance;
    /// The epoch from_index belongs to
    sched::EpochId epoch;
    sched::AudioFormat format;
    /// The first cell a new instance produces, in epoch; one added mid-epoch catches up to it from
    /// the staging ring's history
    int64_t from_index{0};
    bool operator==(const PipelineRequest&) const = default;
    };
    /// @brief One visualizer analysis core wants: the messages of one visualizer stream configuration,
    /// one cell at a time
    struct AnalysisRequest {
    sched::InstanceId instance;
    /// The epoch from_index belongs to
    sched::EpochId epoch;
    VisualizerConfig config;
    /// The first cell a new analysis produces, in epoch
    int64_t from_index{0};
    bool operator==(const AnalysisRequest&) const = default;
    };
    /// @brief Every instance core wants: the frame instances and the visualizer analyses
    struct PipelineSet {
    std::vector<PipelineRequest> frames;
    std::vector<AnalysisRequest> analyses;
    bool operator==(const PipelineSet&) const = default;
    };
    /// @brief Latest wins: core publishes the whole set, the pipelines read the latest. Shared by the
    /// group and its pipelines, whose job may outlive the group.
    class PipelineSetSlot {
    public:
    void publish(std::vector<PipelineRequest> frames, std::vector<AnalysisRequest> analyses = {});
    /// @return the set, when it changed since the version seen, which it updates
    std::optional<PipelineSet> read_if_newer(uint64_t& seen) const;
    private:
    mutable std::mutex mutex_;
    PipelineSet set_;
    uint64_t version_{0};
    };
    // ==============================================================================
    // GroupAudio
    // ==============================================================================
    /// @brief One group's pipelines: one instance per output format, each the input through the channel
    /// mix when the channel count differs, the resampler when the rate differs, the quantize stage and
    /// its PCM, FLAC or Opus encoder. An instance runs each input segment through the stages that
    /// segment needs, so its output format and its frames' cell grid hold across set_format(). Each

Evidence

Confirmed by reading: GroupAudio::emit() and the visualizer cell each build a fresh sched::Frame and move it into the FIFO; FrameEncoder fills the empty payload with assign or resize, so every frame allocates.

Direction

The owner's call, since it adds a cross-thread channel (docs/conventions.md, docs/internals.md's channel table). Options:

  • A bounded return FIFO per group from core to GroupAudio, which core fills with the payloads its rings drop and the audio threads take from before allocating.
  • Placed payload storage per ring (a slab of fixed-size slots sized from the format's largest frame), with a slot released by core and reused by the producer.
  • Leave it: the allocation is outside the host's write() path and bounded by the frame rate.

Done when

  • In steady state, producing and dropping frames allocates nothing on either thread.
  • The new channel, if any, is in docs/internals.md and bounded.

Activity

  1. self-assigned this
    on Oct 16, 2026
  2. kahrendt commented on Oct 16, 2026

    @kahrendt
    ContributorAuthor

    Claimed 14643e23; branch issue-68-frame-payload-slab

  3. kahrendt commented on Oct 16, 2026

    @kahrendt
    ContributorAuthor

    Draft PR: #97

  4. added a commit that references this issue on Oct 16, 2026
    655a5db
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions