///|
using @dsp {type AudioBuffer, type DspContext}
///|
using @graph {
type ControlBindingBuilder,
type ControlBindingError,
type ControlBindingMap,
type CompiledDsp,
type CompiledTemplate,
type GraphControl,
type GraphControlError,
type GraphParamSlot,
}
///|
pub enum VoiceState {
Idle
Active
Releasing
} derive(Eq, Debug)
///|
pub impl Show for VoiceState with fn output(self, logger) {
logger.write_string(@debug.to_string(self))
}
///|
const VOICE_HANDLE_PACK_FACTOR : Int64 = 2147483648L
///|
/// Generation-tagged voice handle.
/// WHY generation counter: slot indices are reused after voice stealing.
/// Without a generation tag, a stale handle from a stolen note could
/// accidentally gate_off or pan the wrong voice occupying that slot.
/// Every note_on increments the slot's generation; all operations compare
/// the handle's generation against the slot's current generation.
#valtype
pub struct VoiceHandle {
slot : Int
generation : Int
} derive(Eq, Debug)
///|
pub impl Show for VoiceHandle with fn output(self, logger) {
logger.write_string(@debug.to_string(self))
}
///|
/// Construct a handle from primitive parts for host-side fixed storage.
pub fn VoiceHandle::from_parts(slot~ : Int, generation~ : Int) -> VoiceHandle {
{ slot, generation, }
}
///|
fn VoiceHandle::pack_parts(slot~ : Int, generation~ : Int) -> Int64 {
slot.to_int64() * VOICE_HANDLE_PACK_FACTOR + generation.to_int64()
}
///|
pub fn VoiceHandle::packed_slot(handle_id : Int64) -> Int {
(handle_id / VOICE_HANDLE_PACK_FACTOR).to_int()
}
///|
pub fn VoiceHandle::packed_generation(handle_id : Int64) -> Int {
(handle_id % VOICE_HANDLE_PACK_FACTOR).to_int()
}
///|
pub(all) enum BoundVoicePoolError {
InvalidMaxVoices
OrphanAdsr
CompileRejected
Binding(ControlBindingError)
} derive(Eq, Debug)
///|
pub impl Show for BoundVoicePoolError with fn output(self, logger) {
logger.write_string(@debug.to_string(self))
}
///|
/// Failure modes for VoicePool construction and template replacement.
/// Mirrors BoundVoicePoolError minus the Binding(...) variant
/// (VoicePool has no bindings).
pub(all) enum VoicePoolError {
InvalidMaxVoices
OrphanAdsr
CompileRejected
} derive(Eq, Debug)
///|
pub impl Show for VoicePoolError with fn output(self, logger) {
logger.write_string(@debug.to_string(self))
}
///|
/// Lift a VoicePoolError to a BoundVoicePoolError. Used by
/// BoundVoicePool's constructors after delegating to
/// validate_voice_template (which now returns VoicePoolError after
/// the boundary-type migration per ADR-0010).
pub fn BoundVoicePoolError::from_voice_pool(
e : VoicePoolError,
) -> BoundVoicePoolError {
match e {
VoicePoolError::InvalidMaxVoices => BoundVoicePoolError::InvalidMaxVoices
VoicePoolError::OrphanAdsr => BoundVoicePoolError::OrphanAdsr
VoicePoolError::CompileRejected => BoundVoicePoolError::CompileRejected
}
}
///|
pub(all) enum VoiceControlError {
InvalidVoiceHandle(VoiceHandle)
Graph(GraphControlError)
} derive(Debug)
///|
pub impl Show for VoiceControlError with fn output(self, logger) {
logger.write_string(@debug.to_string(self))
}
///|
priv struct VoiceSlot {
mut compiled : CompiledDsp?
/// WHY per-slot snapshot: after set_template, the pool's
/// adsr_authoring_indices changes. note_off must iterate the SAME
/// indices used at note_on to gate the right ADSRs. Without this,
/// gate_off would target wrong nodes (or miss freshly-orphaned ADSRs)
/// if the new template differs.
mut adsr_authoring_indices_snapshot : FixedArray[Int]
mut state : VoiceState
mut generation : Int
mut allocation_order : Int
mut template_generation : Int
/// WHY cached gains instead of a pan field: avoids 2 trig calls (cos + sin)
/// per voice per block in process(). Updated only when pan changes
/// (control-rate), not per block. The pan position is fully encoded by the
/// gain pair — no separate pan field needed.
mut pan_left_gain : Double
mut pan_right_gain : Double
mut send_gain : Double
mono_buffer : AudioBuffer
}
///|
#alias(new)
fn VoiceSlot::VoiceSlot(block_size : Int) -> VoiceSlot {
{
compiled: None,
adsr_authoring_indices_snapshot: FixedArray::make(0, 0),
state: VoiceState::Idle,
generation: 0,
allocation_order: 0,
template_generation: -1,
// Center pan: cos(π/4) = sin(π/4) ≈ 0.7071
pan_left_gain: @dsp.pan_left_gain(0.0),
pan_right_gain: @dsp.pan_right_gain(0.0),
send_gain: 0.0,
mono_buffer: AudioBuffer::filled(block_size),
}
}
///|
fn VoiceSlot::is_handle_valid(self : VoiceSlot, handle : VoiceHandle) -> Bool {
handle.generation == self.generation
}
///|
fn VoiceSlot::update_pan(self : VoiceSlot, pan : Double) -> Unit {
let clamped = pan.clamp(min=-1.0, max=1.0)
self.pan_left_gain = @dsp.pan_left_gain(clamped)
self.pan_right_gain = @dsp.pan_right_gain(clamped)
}
///|
fn VoiceSlot::update_send(self : VoiceSlot, gain : Double) -> Unit {
self.send_gain = if !@dsp.is_finite(gain) {
0.0
} else {
gain.clamp(min=0.0, max=1.0)
}
}
///|
/// Gate on or off all surviving ADSR nodes using authoring indices.
/// WHY authoring indices: CompiledDsp::gate_on/gate_off accept original
/// authoring indices and internally map through index_map (which
/// accounts for optimizer elimination + topological reordering).
/// WHY unwrap: VoicePool construction rejects orphan ADSRs (see
/// validate_voice_template) and adsr_authoring_indices is a snapshot of
/// surviving ADSR indices only. Gate operations on those indices must
/// succeed; an Err here means an invariant has been violated.
fn gate_adsrs_at_bool(
compiled : CompiledDsp,
authoring_indices : FixedArray[Int],
gate_on : Bool,
) -> Bool {
for n in 0.. Unit {
if !gate_adsrs_at_bool(compiled, authoring_indices, gate_on) {
abort("voice ADSR gate invariant violated")
}
}
///|
/// Resolve a VoiceHandle to the slot it references, returning None if the
/// handle is out of bounds or stale (slot was stolen and reused).
fn VoicePool::resolve_slot(
self : VoicePool,
handle : VoiceHandle,
) -> VoiceSlot? {
if handle.slot < 0 || handle.slot >= self.max_voices {
return None
}
let slot = self.slots[handle.slot]
if !slot.is_handle_valid(handle) {
return None
}
Some(slot)
}
///|
pub struct VoicePool {
priv slots : FixedArray[VoiceSlot]
priv mut adsr_authoring_indices : FixedArray[Int]
priv mut compiled_template : CompiledTemplate
priv compile_context : DspContext
priv mut next_allocation_order : Int
priv mut template_generation : Int
priv max_voices : Int
priv mut last_sanitized_count : Int
}
///|
fn validate_voice_template(
compiled_template : CompiledTemplate,
context : DspContext,
) -> Result[Unit, VoicePoolError] {
if compiled_template.orphan_adsr_count() > 0 {
return Err(VoicePoolError::OrphanAdsr)
}
// Sanity-compile to catch non-orphan problems (feedback cycle validation
// etc.). Result discarded — per-voice slots compile their own on note_on.
if CompiledDsp::compile(compiled_template, context) is None {
return Err(VoicePoolError::CompileRejected)
}
Ok(())
}
///|
/// WHY test_compile: validates the template structurally (reachable Output
/// node, no invalid cycles, valid node indices) before accepting it. This
/// is a correctness gate so that per-voice note_on compiles can assume the
/// template is well-formed. The compiled result is discarded.
/// WHY orphan-ADSR reject: `VoicePool::note_on` calls `gate_on` for every
/// ADSR index in the template. If an ADSR is authored into the template
/// but not wired to Output, optimize_graph eliminates it and `gate_on`
/// would now return `Err(OrphanNode)`. Rejecting at pool-construction
/// time keeps the per-note `gate_adsrs_at(...)` loop infallible (it can
/// `.unwrap()`) and surfaces the misconfiguration at the earliest
/// observable point rather than at every note trigger.
/// WHY adsr_authoring_indices snapshot: the pool's long-lived snapshot
/// (used by note_on to gate ADSRs, and refreshed by set_template) stores
/// only the indices needed for gating — not the full template — so the
/// caller's CompiledTemplate can be freely discarded after VoicePool::new
/// returns. optimize_graph does not mutate its input, so neither
/// CompiledTemplate::analyze nor CompiledDsp::compile disturbs the
/// caller's array.
pub fn VoicePool::new(
compiled_template : CompiledTemplate,
context : DspContext,
max_voices? : Int = 32,
) -> Result[VoicePool, VoicePoolError] {
if max_voices <= 0 {
return Err(VoicePoolError::InvalidMaxVoices)
}
match validate_voice_template(compiled_template, context) {
Err(error) => Err(error)
Ok(_) => {
let block_size = context.block_size()
let slots = FixedArray::makei(max_voices, _ => VoiceSlot::new(block_size))
for i in 0.. {
slots[i].compiled = Some(compiled)
slots[i].template_generation = 0
}
None => return Err(VoicePoolError::CompileRejected)
}
}
Ok({
slots,
adsr_authoring_indices: compiled_template.adsr_authoring_indices(),
compiled_template,
compile_context: context,
next_allocation_order: 0,
template_generation: 0,
max_voices,
last_sanitized_count: 0,
})
}
}
}
///|
pub fn VoicePool::set_template(
self : VoicePool,
compiled_template : CompiledTemplate,
) -> Result[Unit, VoicePoolError] {
match validate_voice_template(compiled_template, self.compile_context) {
Err(error) => return Err(error)
Ok(_) => ()
}
self.compiled_template = compiled_template
self.adsr_authoring_indices = compiled_template.adsr_authoring_indices()
self.template_generation = self.next_template_generation()
Ok(())
}
///|
fn VoicePool::next_template_generation(self : VoicePool) -> Int {
if self.template_generation >= @int.MAX_VALUE - 1 {
for i in 0.. Result[BoundVoicePool, BoundVoicePoolError] {
let pool = match VoicePool::new(compiled_template, context, max_voices~) {
Ok(pool) => pool
Err(error) => return Err(BoundVoicePoolError::from_voice_pool(error))
}
match bindings.build(compiled_template) {
Ok(bindings) => Ok({ pool, bindings, })
Err(error) => Err(BoundVoicePoolError::Binding(error))
}
}
///|
pub fn BoundVoicePool::set_template(
self : BoundVoicePool,
compiled_template : CompiledTemplate,
bindings : ControlBindingBuilder,
) -> Result[Unit, BoundVoicePoolError] {
match validate_voice_template(compiled_template, self.pool.compile_context) {
Err(error) => return Err(BoundVoicePoolError::from_voice_pool(error))
Ok(_) => ()
}
let next_bindings = match bindings.build(compiled_template) {
Ok(bindings) => bindings
Err(error) => return Err(BoundVoicePoolError::Binding(error))
}
match self.pool.set_template(compiled_template) {
Err(error) => return Err(BoundVoicePoolError::from_voice_pool(error))
Ok(_) => ()
}
self.bindings = next_bindings
Ok(())
}
///|
pub fn BoundVoicePool::note_on_controls(
self : BoundVoicePool,
controls : Map[String, Double],
) -> VoiceHandle? {
let resolved = match self.note_gate(controls) {
InvalidDuration => return None
EventEnd => self.bindings.resolve_controls(controls)
AfterSeconds(_) => {
// Resolve a private copy so callers can reuse their control map.
let held = controls.copy()
held["envelope_decay"] = 0.0
held["envelope_sustain"] = 1.0
self.bindings.resolve_controls(held)
}
}
self.pool.note_on(resolved)
}
///|
/// Trigger a voice with an already-materialized graph-control batch.
/// This bypasses string-key control-map resolution while preserving the same
/// transactional semantics as `VoicePool::note_on`.
pub fn BoundVoicePool::note_on_graph_controls(
self : BoundVoicePool,
controls : Array[GraphControl],
) -> VoiceHandle? {
self.pool.note_on(controls)
}
///|
/// Trigger a voice from four fixed, prevalidated parameter updates and set its
/// pan before it becomes visible to callers. This is for host integrations that
/// validate a stable template outside the audio callback and need a non-allocating
/// note-on path. Returns a negative id on rejection; dynamic graph-control callers
/// should use `note_on_graph_controls`.
pub fn BoundVoicePool::note_on_prevalidated_params4_id(
self : BoundVoicePool,
node0 : Int,
slot0 : GraphParamSlot,
value0 : Double,
node1 : Int,
slot1 : GraphParamSlot,
value1 : Double,
node2 : Int,
slot2 : GraphParamSlot,
value2 : Double,
node3 : Int,
slot3 : GraphParamSlot,
value3 : Double,
pan : Double,
) -> Int64 {
self.pool.note_on_prevalidated_params4_id(
node0, slot0, value0, node1, slot1, value1, node2, slot2, value2, node3, slot3,
value3, pan,
)
}
///|
/// Deprecated compatibility wrapper. Use `note_off_result` to observe stale
/// or invalid handles as `VoiceControlError` values.
#deprecated("Use BoundVoicePool::note_off_result(handle) to observe VoiceControlError")
pub fn BoundVoicePool::note_off(
self : BoundVoicePool,
handle : VoiceHandle,
) -> Bool {
self.note_off_bool(handle)
}
///|
/// Allocation-conscious note-off predicate for host callback paths.
pub fn BoundVoicePool::note_off_bool(
self : BoundVoicePool,
handle : VoiceHandle,
) -> Bool {
self.pool.note_off_bool(handle)
}
///|
pub fn BoundVoicePool::note_off_result(
self : BoundVoicePool,
handle : VoiceHandle,
) -> Result[Unit, VoiceControlError] {
self.pool.note_off_result(handle)
}
///|
/// Deprecated compatibility wrapper. Use `kill_result` to observe stale or
/// invalid handles as `VoiceControlError` values.
#deprecated("Use BoundVoicePool::kill_result(handle) to observe VoiceControlError")
pub fn BoundVoicePool::kill(
self : BoundVoicePool,
handle : VoiceHandle,
) -> Bool {
self.kill_result(handle) is Ok(_)
}
///|
pub fn BoundVoicePool::kill_result(
self : BoundVoicePool,
handle : VoiceHandle,
) -> Result[Unit, VoiceControlError] {
self.pool.kill_result(handle)
}
///|
pub fn BoundVoicePool::note_off_all(self : BoundVoicePool) -> Unit {
self.pool.note_off_all()
}
///|
pub fn BoundVoicePool::kill_all(self : BoundVoicePool) -> Unit {
self.pool.kill_all()
}
///|
/// Deprecated compatibility wrapper. Use `set_voice_pan_result` to observe
/// stale or invalid handles as `VoiceControlError` values.
#deprecated("Use BoundVoicePool::set_voice_pan_result(handle, pan) to observe VoiceControlError")
pub fn BoundVoicePool::set_voice_pan(
self : BoundVoicePool,
handle : VoiceHandle,
pan : Double,
) -> Bool {
self.set_voice_pan_bool(handle, pan)
}
///|
/// Allocation-conscious pan update predicate for host callback paths.
pub fn BoundVoicePool::set_voice_pan_bool(
self : BoundVoicePool,
handle : VoiceHandle,
pan : Double,
) -> Bool {
self.pool.set_voice_pan_bool(handle, pan)
}
///|
pub fn BoundVoicePool::set_voice_pan_result(
self : BoundVoicePool,
handle : VoiceHandle,
pan : Double,
) -> Result[Unit, VoiceControlError] {
self.pool.set_voice_pan_result(handle, pan)
}
///|
/// Set this voice's gain into the pool's single stereo send output.
pub fn BoundVoicePool::set_voice_send_result(
self : BoundVoicePool,
handle : VoiceHandle,
gain : Double,
) -> Result[Unit, VoiceControlError] {
self.pool.set_voice_send_result(handle, gain)
}
///|
/// Validate a live control batch against an active voice without mutating it.
pub fn BoundVoicePool::validate_voice_controls_result(
self : BoundVoicePool,
handle : VoiceHandle,
controls : Array[GraphControl],
) -> Result[Unit, VoiceControlError] {
self.pool.validate_voice_controls_result(handle, controls)
}
///|
/// Apply one validated graph control to an already-sounding voice.
pub fn BoundVoicePool::apply_voice_control_result(
self : BoundVoicePool,
handle : VoiceHandle,
control : GraphControl,
) -> Result[Unit, VoiceControlError] {
self.pool.apply_voice_controls_result(handle, [control])
}
///|
/// Allocation-conscious live parameter update predicate for host callback paths.
pub fn BoundVoicePool::apply_voice_param_bool(
self : BoundVoicePool,
handle : VoiceHandle,
node_index : Int,
slot : GraphParamSlot,
value : Double,
) -> Bool {
self.pool.apply_voice_param_bool(handle, node_index, slot, value)
}
///|
/// Apply a graph-control batch transactionally to an already-sounding voice.
pub fn BoundVoicePool::apply_voice_controls_result(
self : BoundVoicePool,
handle : VoiceHandle,
controls : Array[GraphControl],
) -> Result[Unit, VoiceControlError] {
self.pool.apply_voice_controls_result(handle, controls)
}
///|
pub fn BoundVoicePool::voice_state(
self : BoundVoicePool,
handle : VoiceHandle,
) -> VoiceState {
self.pool.voice_state(handle)
}
///|
pub fn BoundVoicePool::active_voice_count(self : BoundVoicePool) -> Int {
self.pool.active_voice_count()
}
///|
pub fn BoundVoicePool::last_sanitized_count(self : BoundVoicePool) -> Int {
self.pool.last_sanitized_count()
}
///|
pub fn BoundVoicePool::process(
self : BoundVoicePool,
context : DspContext,
left : AudioBuffer,
right : AudioBuffer,
) -> Unit {
self.pool.process(context, left, right)
}
///|
/// Render dry stereo and the pool's single post-pan stereo send in one pass.
pub fn BoundVoicePool::process_with_send(
self : BoundVoicePool,
context : DspContext,
left : AudioBuffer,
right : AudioBuffer,
send_left : AudioBuffer,
send_right : AudioBuffer,
) -> Unit {
self.pool.process_with_send(context, left, right, send_left, send_right)
}
///|
pub fn VoicePool::active_voice_count(self : VoicePool) -> Int {
let count = for i in 0.. Int {
self.last_sanitized_count
}
///|
/// WHY three-tier priority: Idle slots are free — no audible cost.
/// Releasing voices are already fading out, so stealing them causes minimal
/// pop. Active voices are a last resort — cutting them is audible but
/// unavoidable at full polyphony. Within each tier, oldest-first ensures
/// the most recently played notes survive longest.
fn VoicePool::find_slot(self : VoicePool) -> Int {
// Priority 1: any idle slot (free)
for i = 0; i < self.max_voices; i = i + 1 {
if self.slots[i].state is VoiceState::Idle {
return i
}
}
// Priority 2: oldest releasing voice (least audible pop)
let mut best_releasing = -1
let mut best_releasing_order = @int.MAX_VALUE
for i = 0; i < self.max_voices; i = i + 1 {
if self.slots[i].state is VoiceState::Releasing &&
self.slots[i].allocation_order < best_releasing_order {
best_releasing = i
best_releasing_order = self.slots[i].allocation_order
}
}
if best_releasing >= 0 {
return best_releasing
}
// Priority 3: oldest active voice (audible cut, last resort)
let mut best_active = 0
let mut best_active_order = @int.MAX_VALUE
for i = 0; i < self.max_voices; i = i + 1 {
if self.slots[i].allocation_order < best_active_order {
best_active = i
best_active_order = self.slots[i].allocation_order
}
}
best_active
}
///|
/// Reassign allocation_order values to a dense sequential range (0, 1, 2, ...)
/// preserving relative ordering. Called when next_allocation_order is about to
/// overflow. This keeps the oldest-first voice-stealing heuristic correct.
fn VoicePool::compact_allocation_orders(self : VoicePool) -> Unit {
// Collect (slot_index, allocation_order) pairs for non-idle slots
let pairs = [
for i in 0.. {
(i, self.slots[i].allocation_order)
}
]
// Sort by allocation_order ascending to preserve relative age
pairs.sort_by(fn(a, b) { a.1.compare(b.1) })
// Reassign sequential orders starting from 0
for j = 0; j < pairs.length(); j = j + 1 {
self.slots[pairs[j].0].allocation_order = j
}
// Set pool counter to next available value
self.next_allocation_order = pairs.length()
}
///|
fn VoicePool::activate_slot(
self : VoicePool,
slot : VoiceSlot,
slot_index : Int,
compiled : CompiledDsp,
update_pan : Bool,
pan : Double,
) -> Int64 {
gate_adsrs_at(compiled, self.adsr_authoring_indices, true)
slot.compiled = Some(compiled)
slot.template_generation = self.template_generation
// WHY direct snapshot reference: `set_template` replaces the pool snapshot
// instead of mutating it. Holding the current FixedArray preserves the note-on
// ADSR set for later note_off without allocating a per-note copy.
slot.adsr_authoring_indices_snapshot = self.adsr_authoring_indices
if update_pan {
slot.update_pan(pan)
}
slot.send_gain = 0.0
slot.state = VoiceState::Active
// WHY overflow guard on generation: after 2^31 increments the counter
// wraps negative, which could revalidate stale VoiceHandles. Resetting
// to 0 safely invalidates all outstanding handles for this slot.
slot.generation = if slot.generation >= @int.MAX_VALUE - 1 {
0
} else {
slot.generation + 1
}
slot.allocation_order = self.next_allocation_order
// WHY overflow guard on allocation_order: if the pool-wide counter wraps
// negative, the oldest-first stealing heuristic breaks. Compact all slot
// orders into a dense sequential range preserving relative order.
if self.next_allocation_order >= @int.MAX_VALUE - 1 {
self.compact_allocation_orders()
} else {
self.next_allocation_order = self.next_allocation_order + 1
}
VoiceHandle::pack_parts(slot=slot_index, generation=slot.generation)
}
///|
fn VoicePool::note_on_prevalidated_params4_id(
self : VoicePool,
node0 : Int,
slot0 : GraphParamSlot,
value0 : Double,
node1 : Int,
slot1 : GraphParamSlot,
value1 : Double,
node2 : Int,
slot2 : GraphParamSlot,
value2 : Double,
node3 : Int,
slot3 : GraphParamSlot,
value3 : Double,
pan : Double,
) -> Int64 {
let slot_index = self.find_slot()
let slot = self.slots[slot_index]
let compiled = match slot.compiled {
Some(existing) if slot.template_generation == self.template_generation =>
existing
_ => return -1L
}
if !compiled.can_set_param_bool(node0, slot0, value0) ||
!compiled.can_set_param_bool(node1, slot1, value1) ||
!compiled.can_set_param_bool(node2, slot2, value2) ||
!compiled.can_set_param_bool(node3, slot3, value3) {
return -1L
}
compiled.reset_runtime_state()
if !compiled.set_param_bool(node0, slot0, value0) ||
!compiled.set_param_bool(node1, slot1, value1) ||
!compiled.set_param_bool(node2, slot2, value2) ||
!compiled.set_param_bool(node3, slot3, value3) {
return -1L
}
self.activate_slot(slot, slot_index, compiled, true, pan)
}
///|
pub fn VoicePool::note_on(
self : VoicePool,
params : Array[GraphControl],
) -> VoiceHandle? {
let slot_index = self.find_slot()
let slot = self.slots[slot_index]
// WHY fresh compile on active-slot params: keep the old active slot untouched
// if a stealing note's params reject. Idle slots can reset/reuse their
// prepared graph because no old voice can be damaged by a rejected note-on.
let force_fresh = !(slot.state is VoiceState::Idle) && !params.is_empty()
let mut reused_prepared = false
let compiled = match slot.compiled {
Some(existing) if !force_fresh &&
slot.template_generation == self.template_generation => {
reused_prepared = true
existing
}
_ =>
match CompiledDsp::compile(self.compiled_template, self.compile_context) {
Some(c) => c
None => return None
}
}
if reused_prepared {
compiled.reset_runtime_state()
}
// WHY transactional: if any param is invalid, the voice is not activated.
// This prevents partially-configured voices with default/stale settings.
if !params.is_empty() {
if compiled.apply_controls(params) is Err(_) {
return None
}
}
let handle_id = self.activate_slot(slot, slot_index, compiled, false, 0.0)
Some(
VoiceHandle::from_parts(
slot=VoiceHandle::packed_slot(handle_id),
generation=VoiceHandle::packed_generation(handle_id),
),
)
}
///|
/// Deprecated compatibility wrapper. Use `note_off_result` to observe stale
/// or invalid handles as `VoiceControlError` values.
#deprecated("Use VoicePool::note_off_result(handle) to observe VoiceControlError")
pub fn VoicePool::note_off(self : VoicePool, handle : VoiceHandle) -> Bool {
self.note_off_bool(handle)
}
///|
/// Allocation-conscious note-off predicate for host callback paths.
fn VoicePool::note_off_bool(self : VoicePool, handle : VoiceHandle) -> Bool {
match self.resolve_slot(handle) {
None => false
Some(slot) => {
if slot.state is VoiceState::Idle {
return false
}
match slot.compiled {
Some(compiled) =>
if gate_adsrs_at_bool(
compiled,
slot.adsr_authoring_indices_snapshot,
false,
) {
slot.state = VoiceState::Releasing
true
} else {
false
}
None => false
}
}
}
}
///|
pub fn VoicePool::note_off_result(
self : VoicePool,
handle : VoiceHandle,
) -> Result[Unit, VoiceControlError] {
match self.resolve_slot(handle) {
None => Err(VoiceControlError::InvalidVoiceHandle(handle))
Some(slot) => {
if slot.state is VoiceState::Idle {
return Err(VoiceControlError::InvalidVoiceHandle(handle))
}
match slot.compiled {
// WHY adsr_authoring_indices_snapshot: uses the indices from
// note_on time, not the current self.adsr_authoring_indices which
// may have changed via set_template.
Some(compiled) =>
gate_adsrs_at(compiled, slot.adsr_authoring_indices_snapshot, false)
None => return Err(VoiceControlError::InvalidVoiceHandle(handle))
}
slot.state = VoiceState::Releasing
Ok(())
}
}
}
///|
fn VoicePool::kill_result(
self : VoicePool,
handle : VoiceHandle,
) -> Result[Unit, VoiceControlError] {
match self.resolve_slot(handle) {
None => Err(VoiceControlError::InvalidVoiceHandle(handle))
Some(slot) => {
if slot.state is VoiceState::Idle {
return Err(VoiceControlError::InvalidVoiceHandle(handle))
}
match slot.compiled {
Some(compiled) => compiled.reset_runtime_state()
None => ()
}
slot.state = VoiceState::Idle
Ok(())
}
}
}
///|
pub fn VoicePool::note_off_all(self : VoicePool) -> Unit {
for i = 0; i < self.max_voices; i = i + 1 {
let slot = self.slots[i]
if slot.state is VoiceState::Active {
match slot.compiled {
Some(compiled) =>
gate_adsrs_at(compiled, slot.adsr_authoring_indices_snapshot, false)
None => ()
}
slot.state = VoiceState::Releasing
}
}
}
///|
pub fn VoicePool::kill_all(self : VoicePool) -> Unit {
for i = 0; i < self.max_voices; i = i + 1 {
let slot = self.slots[i]
if slot.state is VoiceState::Idle {
continue i + 1
}
match slot.compiled {
Some(compiled) => compiled.reset_runtime_state()
None => ()
}
slot.state = VoiceState::Idle
}
}
///|
/// Deprecated compatibility wrapper. Use `set_voice_pan_result` to observe
/// stale or invalid handles as `VoiceControlError` values.
#deprecated("Use VoicePool::set_voice_pan_result(handle, pan) to observe VoiceControlError")
pub fn VoicePool::set_voice_pan(
self : VoicePool,
handle : VoiceHandle,
pan : Double,
) -> Bool {
self.set_voice_pan_bool(handle, pan)
}
///|
/// Allocation-conscious pan update predicate for host callback paths.
fn VoicePool::set_voice_pan_bool(
self : VoicePool,
handle : VoiceHandle,
pan : Double,
) -> Bool {
match self.resolve_slot(handle) {
None => false
Some(slot) => {
slot.update_pan(pan)
true
}
}
}
///|
pub fn VoicePool::set_voice_pan_result(
self : VoicePool,
handle : VoiceHandle,
pan : Double,
) -> Result[Unit, VoiceControlError] {
match self.resolve_slot(handle) {
None => Err(VoiceControlError::InvalidVoiceHandle(handle))
Some(slot) => {
slot.update_pan(pan)
Ok(())
}
}
}
///|
pub fn VoicePool::set_voice_send_result(
self : VoicePool,
handle : VoiceHandle,
gain : Double,
) -> Result[Unit, VoiceControlError] {
match self.resolve_slot(handle) {
None => Err(VoiceControlError::InvalidVoiceHandle(handle))
Some(slot) => {
slot.update_send(gain)
Ok(())
}
}
}
///|
fn VoicePool::compiled_voice_result(
self : VoicePool,
handle : VoiceHandle,
) -> Result[CompiledDsp, VoiceControlError] {
match self.resolve_slot(handle) {
None => Err(VoiceControlError::InvalidVoiceHandle(handle))
Some(slot) =>
if slot.state is VoiceState::Idle {
Err(VoiceControlError::InvalidVoiceHandle(handle))
} else {
match slot.compiled {
Some(compiled) => Ok(compiled)
None => Err(VoiceControlError::InvalidVoiceHandle(handle))
}
}
}
}
///|
fn VoicePool::validate_voice_controls_result(
self : VoicePool,
handle : VoiceHandle,
controls : Array[GraphControl],
) -> Result[Unit, VoiceControlError] {
let compiled = match self.compiled_voice_result(handle) {
Ok(compiled) => compiled
Err(error) => return Err(error)
}
match compiled.validate_controls(controls) {
Ok(_) => Ok(())
Err(error) => Err(VoiceControlError::Graph(error))
}
}
///|
fn VoicePool::apply_voice_controls_result(
self : VoicePool,
handle : VoiceHandle,
controls : Array[GraphControl],
) -> Result[Unit, VoiceControlError] {
let compiled = match self.compiled_voice_result(handle) {
Ok(compiled) => compiled
Err(error) => return Err(error)
}
match compiled.apply_controls(controls) {
Ok(_) => Ok(())
Err(error) => Err(VoiceControlError::Graph(error))
}
}
///|
fn VoicePool::apply_voice_param_bool(
self : VoicePool,
handle : VoiceHandle,
node_index : Int,
slot : GraphParamSlot,
value : Double,
) -> Bool {
match self.resolve_slot(handle) {
None => false
Some(slot_state) =>
if slot_state.state is VoiceState::Idle {
false
} else {
match slot_state.compiled {
Some(compiled) =>
compiled.can_set_param_bool(node_index, slot, value) &&
compiled.set_param_bool(node_index, slot, value)
None => false
}
}
}
}
///|
pub fn VoicePool::voice_state(
self : VoicePool,
handle : VoiceHandle,
) -> VoiceState {
match self.resolve_slot(handle) {
None => VoiceState::Idle
Some(slot) => slot.state
}
}
///|
fn VoicePool::process_outputs(
self : VoicePool,
context : DspContext,
left_output : AudioBuffer,
right_output : AudioBuffer,
send_left : AudioBuffer,
send_right : AudioBuffer,
write_send : Bool,
) -> Unit {
let mut sample_count = context.block_size()
if left_output.length() < sample_count {
sample_count = left_output.length()
}
if right_output.length() < sample_count {
sample_count = right_output.length()
}
if write_send {
if send_left.length() < sample_count {
sample_count = send_left.length()
}
if send_right.length() < sample_count {
sample_count = send_right.length()
}
}
for i = 0; i < self.max_voices; i = i + 1 {
let slot = self.slots[i]
if slot.state is VoiceState::Idle {
continue i + 1
}
match slot.compiled {
None => {
slot.state = VoiceState::Idle
continue i + 1
}
Some(compiled) => {
compiled.process(context, slot.mono_buffer)
// WHY cached gains: pan_left_gain/pan_right_gain are updated only
// when set_voice_pan is called (control-rate), not here (audio-rate).
// This avoids 2 trig calls per voice per block.
let left_gain = slot.pan_left_gain
let right_gain = slot.pan_right_gain
if write_send {
let send_gain = slot.send_gain
for s = 0; s < sample_count; s = s + 1 {
let mono = slot.mono_buffer.get(s)
let left = mono * left_gain
let right = mono * right_gain
left_output.set(s, left_output.get(s) + left)
right_output.set(s, right_output.get(s) + right)
send_left.set(s, send_left.get(s) + left * send_gain)
send_right.set(s, send_right.get(s) + right * send_gain)
}
} else {
for s = 0; s < sample_count; s = s + 1 {
let mono = slot.mono_buffer.get(s)
left_output.set(s, left_output.get(s) + mono * left_gain)
right_output.set(s, right_output.get(s) + mono * right_gain)
}
}
// Reclaim voices whose envelope has finished, whether in Release
// or Active (e.g. sustain=0 decay completed). Without this check,
// silent voices accumulate as zombies and force audible steals.
if compiled.is_voice_finished() {
slot.state = VoiceState::Idle
}
}
}
}
self.last_sanitized_count = @dsp.sanitize_buffer(left_output, sample_count) +
@dsp.sanitize_buffer(right_output, sample_count)
if write_send {
ignore(@dsp.sanitize_buffer(send_left, sample_count))
ignore(@dsp.sanitize_buffer(send_right, sample_count))
}
}
///|
pub fn VoicePool::process(
self : VoicePool,
context : DspContext,
left_output : AudioBuffer,
right_output : AudioBuffer,
) -> Unit {
self.process_outputs(
context, left_output, right_output, left_output, right_output, false,
)
}
///|
/// Render dry stereo and the pool's single post-pan stereo send in one pass.
pub fn VoicePool::process_with_send(
self : VoicePool,
context : DspContext,
left_output : AudioBuffer,
right_output : AudioBuffer,
send_left : AudioBuffer,
send_right : AudioBuffer,
) -> Unit {
self.process_outputs(
context, left_output, right_output, send_left, send_right, true,
)
}
///|
/// Gate timing selected by the instrument. Release continues after gate-off.
pub(all) enum NoteGate {
EventEnd
AfterSeconds(Double)
InvalidDuration
} derive(Debug, Eq)
///|
/// Resolve gate timing without starting a voice or changing the input controls.
/// Attack/release controls use graph milliseconds; hold uses seconds.
/// Instruments without all four envelope bindings retain their event gate.
pub fn BoundVoicePool::note_gate(
self : BoundVoicePool,
controls : Map[String, Double],
) -> NoteGate {
let hold = match controls.get("hold") {
Some(seconds) => seconds
None => return EventEnd
}
let default_attack = match self.bindings.default_value("attack") {
Some(attack) if self.bindings.default_value("envelope_decay") is Some(_) &&
self.bindings.default_value("envelope_sustain") is Some(_) &&
self.bindings.default_value("release") is Some(_) => attack
_ => return EventEnd
}
let attack = controls.get("attack").unwrap_or(default_attack) / 1000.0
if hold.is_nan() ||
hold.is_inf() ||
hold < 0.0 ||
hold > 86400.0 ||
attack.is_nan() ||
attack.is_inf() ||
attack < 0.0 ||
attack + hold > 172800.0 {
InvalidDuration
} else {
AfterSeconds(attack + hold)
}
}