Documentation
¶
Overview ¶
Package physical implements reduced physical models of acoustic drums.
The package is deliberately independent of the sequencer and the existing procedural voices. Physical parameters use SI units; UI mapping and persistence integration belong at the application boundary.
Index ¶
- Constants
- Variables
- func BesselZero(order, radialIndex int) (float64, error)
- func EncodeConfig(config PhysicalDrum) ([]byte, error)
- func HeadCavityCouplingM2(head Head, shellRadiusM float64, mode Mode, cavity CavityMode) float64
- func ModalDecayRatePerSecond(head Head, wavenumberPerM float64) float64
- func NaturalFrequencyHz(head Head, besselZero float64) float64
- func RetuneTension(head *Head, tensionNPerM float64)
- func WaveSpeedMPerS(head Head) float64
- type Attack
- type Cavity
- type CavityMode
- type Contact
- type ContactMetrics
- type ContactModel
- type DoubleHead
- func (d *DoubleHead) BatterMode(index int) (Mode, bool)
- func (d *DoubleHead) BatterModeCount() int
- func (d *DoubleHead) CavityBulkStiffnessPaPerM3() float64
- func (d *DoubleHead) CavityMode(index int) (CavityMode, bool)
- func (d *DoubleHead) CavityModeCount() int
- func (d *DoubleHead) CavityVolumeM3() float64
- func (d *DoubleHead) Config() PhysicalDrum
- func (d *DoubleHead) CouplingChannelCount() int
- func (d *DoubleHead) CouplingCoefficientCount() int
- func (d *DoubleHead) CouplingDivergedSteps() uint64
- func (d *DoubleHead) CouplingPumpModes() []int
- func (d *DoubleHead) CouplingWorstForceHz() float64
- func (d *DoubleHead) IsActive() bool
- func (d *DoubleHead) LastContact() ContactMetrics
- func (d *DoubleHead) PulseSamples() int
- func (d *DoubleHead) Reconfigure(config PhysicalDrum) error
- func (d *DoubleHead) ReferenceFrequencyResponse(frequencyHz float64) (FrequencyResponse, error)
- func (d *DoubleHead) Render(dst []float64)
- func (d *DoubleHead) Reset()
- func (d *DoubleHead) ResonantMode(index int) (Mode, bool)
- func (d *DoubleHead) ResonantModeCount() int
- func (d *DoubleHead) Tick() DoubleHeadOutput
- func (d *DoubleHead) Trigger(velocity01 float64) error
- type DoubleHeadOutput
- type FrequencyResponse
- type Head
- type Mode
- type ModeDecayCorrection
- type NonlinearCoupling
- type Nonlinearity
- type Orientation
- type Output
- type PhysicalDrum
- type Pickup
- type Quality
- type SingleHead
- func (s *SingleHead) IsActive() bool
- func (s *SingleHead) LastContact() ContactMetrics
- func (s *SingleHead) Mode(index int) (Mode, bool)
- func (s *SingleHead) ModeCount() int
- func (s *SingleHead) PulseSamples() int
- func (s *SingleHead) Render(dst []float64)
- func (s *SingleHead) Reset()
- func (s *SingleHead) Tick() Output
- func (s *SingleHead) Trigger(velocity01 float64) error
- type Strike
- type TensionAsymmetry
Constants ¶
const (
// ConfigVersion is the physical-drum JSON schema emitted by EncodeConfig.
ConfigVersion = 11
)
const DefaultResonantModeLimit = 24
DefaultResonantModeLimit is the oscillator budget of a head that is reduced to what the enclosed air can reach — in practice the resonant head.
It is a fixed number rather than a per-tier one, because the two budgets answer different questions. Quality.ModeLimit() buys *bandwidth* on the head the strike drives and the microphone hears, so it belongs on a quality tier. The reduced head is never struck; the cavity is its only excitation, so the span worth covering is the cavity's, and the cavity's basis is a property of the shell rather than of a quality setting. Sharing the tier's number was accidental — it was the budget of the *only* head that had one, back when the reduction left half a dozen modes and the choice could not matter — and it stopped being harmless when the modal cavity widened the reachable set from {0} to {0,1,2}.
The value is the smallest that straddles every transverse cavity resonance, which is the criterion the coupling mechanism actually rests on: a cavity mode drives the resonant modes of its own azimuthal order, and it is represented faithfully only if that family has partials on both sides of it. On the shipped shell the cavity's (1,1) pair sits at 660 Hz, between the resonant (1,2) at 472 Hz and (1,3) at 685 Hz; its (2,1) pair sits at 1094 Hz, between (2,4) at 1001 Hz and (2,5) at 1213 Hz. The (2,5) pair is the 23rd and 24th oscillator of the frequency-ordered reachable bank.
Measured against the uncapped Standard bank, as the difference between the modal cavity and the lumped one that P9/M2 introduced — the 13.1 dB feature at 657.5 Hz — the budget behaves like this:
8 -10.22 dB the (1,3) straddle is missing and the mechanism is 3 dB wrong 12 -13.60 dB 16 -13.46 dB 20 -13.24 dB 24 -13.16 dB 28 -13.13 dB the whole reachable bank
The sharp knee is at 12 and the residual past it is small, but the (2,1) region converges more slowly: 4.7 dB of whole-band transfer-function error at 12, 2.1 dB at 20, 1.1 dB at 24, and 0.1 dB at 26. 24 is where the second knee is, and where both transverse pairs are straddled rather than only the first.
It is also far above every count a lumped cavity can produce — 4, 6 and 8 axisymmetric modes at Draft, Standard and High — so it never binds on a migrated v1–v10 document and those still render bit-identically.
const ReleaseBoundSeconds = 8.0
ReleaseBoundSeconds is how long after a strike the model stops being an active voice, whatever its loss law says.
It exists because the loss law is reachable. `PhysicalTomConfig` multiplies d1, d2 and every mode-decay correction by DAMP x D.TILT, so D.TILT at its lower stop deletes the whole frequency-dependent half of the law and leaves only the flat d0 and radiation terms: measured over the shipped knob ranges, D.TILT alone takes the release from 1.5 s to over 21 s, and the DAMP-min / D.TILT-0 / DEC-max corner to over 65 s. A note held for a minute after the player struck it is not a long decay, it is a stuck voice.
The number is a product statement and has to be one. A deadline derived from the generated bank would scale *with* the pathology — at D.TILT 0 the bank's own slowest mode is the problem — so it would agree with whatever the knobs did. 8 s is 5.2x the shipped voice's own 1.527 s release, which is what makes the bound provably inert on everything that ships; `just check-physical-reference` staying clean is the standing proof of that, since the analysis renders 1.2-2 s windows.
It is exported so the product-side sweep in internal/drum asserts against this constant rather than against a second copy of the number.
Variables ¶
var ( // ErrConfigVersion reports an unsupported persisted configuration version. ErrConfigVersion = errors.New("unsupported physical drum config version") // ErrInvalidConfig reports a non-finite or out-of-range physical parameter. ErrInvalidConfig = errors.New("invalid physical drum config") )
var ErrInvalidFrequency = errors.New("analysis frequency must be finite and in (0, Nyquist)")
var ErrInvalidModeIndex = errors.New("invalid circular mode index")
var ErrInvalidVelocity = errors.New("physical strike velocity must be finite and in [0,1]")
Functions ¶
func BesselZero ¶
BesselZero returns the radialIndex-th positive zero of J_order.
func EncodeConfig ¶
func EncodeConfig(config PhysicalDrum) ([]byte, error)
EncodeConfig validates and serializes a physical-drum configuration.
func HeadCavityCouplingM2 ¶
func HeadCavityCouplingM2( head Head, shellRadiusM float64, mode Mode, cavity CavityMode, ) float64
HeadCavityCouplingM2 is the overlap integral of one head mode shape against one cavity mode shape over the head's disc,
C_ic = integral phi_i Psi_c dA,
which is the coefficient that appears symmetrically in both directions of the coupling: the cavity mode is driven by C_ic*qdot_i and the head mode is loaded by C_ic*P_c. That symmetry is what makes the coupled system passive.
The azimuthal integral separates and gives a selection rule: it vanishes unless the azimuthal orders match, and for matching orders it is a rotation through m*psi, where psi is the head's principal tension axis. At psi = 0 — every shipped configuration — that rotation is the identity and orientations must match too, so each head mode couples to at most one cavity mode per radial order.
The uniform cavity mode is returned as the mode's own analytic swept area rather than through the quadrature below. The two agree to roundoff, and the test suite checks that they do; using the analytic value keeps a one-mode cavity numerically identical to the model this replaced.
func ModalDecayRatePerSecond ¶
ModalDecayRatePerSecond evaluates the structural loss law d(k) = d0 + d1*k + d2*k². Radiation loss and optional measured residuals remain separate in Mode so calibration can distinguish their causes.
The k¹ term carries constant Q. Without it the law cannot express a fixed fraction of critical damping at all: d0 alone gives T60 independent of frequency and d2 alone gives T60 ∝ 1/f², where the measured membrane behaviour is T60 ∝ 1/f.
func NaturalFrequencyHz ¶
NaturalFrequencyHz evaluates the membrane/plate dispersion relation.
func RetuneTension ¶
RetuneTension sets a head's tension and rescales the loss terms that constant Q makes proportional to the wave speed, so that the tuning knob changes the drum's pitch and not its ring.
The coefficients on Head are quoted at whatever tension the head carries when this is called. Constant ζ means γ = ζω = ζ·c·k, so the k¹ coefficient *is* ζc; the k² coefficient is the same statement one order out; and the (0,1) correction stands for a coupling loss, which is a fraction of that mode's own ω. All three are therefore proportional to c = √(T/σ). Loss0PerSecond is a frequency-independent floor and by definition does not move. RadiationLossPerSecond also stays put: its frequency dependence lives in the radiation amplitude it multiplies, not in the coefficient.
Without this the tuning knob is secretly a sustain control. Measured on the shipped coefficients, ζ ran from 2.20 % at the bottom of B.TUNE's range to 0.72 % at the top, which stretched a 300 Hz partial's T60 from 0.166 s to 0.423 s — so "it only sounds good at high B.TUNE" was in part a report about decay length, not pitch.
func WaveSpeedMPerS ¶
WaveSpeedMPerS returns the transverse wave speed √(T/σ) that sets the head's whole mode series.
Types ¶
type Attack ¶
type Attack struct {
Enabled bool `json:"enabled"`
LevelRelative float64 `json:"levelRelative"`
CentreHz float64 `json:"centreHz"`
QualityFactor float64 `json:"qualityFactor"`
DecayScale float64 `json:"decayScale"`
}
Attack is the stochastic high-band layer that stands in for the modes this model cannot afford to resolve. LevelRelative is fitted against the modal layer; CentreHz places the band group and QualityFactor sets the width of each of its bands.
DecayScale is a dimensionless multiplier on releases that are otherwise *derived*: each band decays at the batter head's own structural loss law evaluated at that band's centre, so the layer extrapolates the mode series instead of ringing at a rate of its own. It replaced an absolute DecaySeconds, which was a fitted 20 ms — a 138 ms T60 held flat across 1-8 kHz, against a loss law that puts that band between 75 ms and 18 ms. The scale exists at all only because the law is being read past the range it was fitted in.
type Cavity ¶
type Cavity struct {
Enabled bool `json:"enabled"`
DepthM float64 `json:"depthM"`
Coupling01 float64 `json:"coupling01"`
StiffnessScale float64 `json:"stiffnessScale"`
AirDensityKgPerM3 float64 `json:"airDensityKgPerM3"`
SoundSpeedMPerS float64 `json:"soundSpeedMPerS"`
LossPerSecond float64 `json:"lossPerSecond"`
ModeCount int `json:"modeCount"`
}
Cavity describes the lumped enclosed-air spring and its pressure loss.
StiffnessScale multiplies the rigid-enclosure bulk stiffness rho*c^2/V. A real shell is not rigid and its heads are not pistons, so the rigid formula badly over-predicts how much the air stiffens the axisymmetric modes; the scale is the one place that discrepancy is absorbed. The vent is a third candidate and a small one: a port is a Helmholtz high-pass, and a 10 mm vent in this shell tunes at 32 Hz and diverts under 5 % of the flow at the 150 Hz fundamental, where the fitted scale is a factor of twelve. See docs/physical-cavity.md. It is a fraction rather than a free gain because the rigid, sealed, piston-driven enclosure is the stiffest case there is — 1 is the physical ceiling, not a neutral setting. It multiplies every cavity mode's stiffness alike, so the ceiling keeps its meaning once the air carries more than one state.
ModeCount is how many enclosed-air pressure states the cavity carries, chosen in frequency order from the rigid-walled cylinder's axially uniform family; an m > 0 mode costs two states for its cosine and sine members. 1 is the single uniform compliance this model had before transverse modes existed, and is the exact reproduction of it. See cavity.go for the basis and docs/physical-cavity.md for what the transverse modes buy.
type CavityMode ¶
type CavityMode struct {
AzimuthalOrder int `json:"azimuthalOrder"`
RadialOrder int `json:"radialOrder"`
Orientation Orientation `json:"orientation"`
NeumannZero float64 `json:"neumannZero"`
FrequencyHz float64 `json:"frequencyHz"`
AngularFrequency float64 `json:"angularFrequency"`
// VolumeNormM3 is Lambda_c = integral of Psi_c^2 over the cavity volume.
VolumeNormM3 float64 `json:"volumeNormM3"`
// StiffnessPaPerM3 is K_c = s*rho*c^2/Lambda_c, the modal air spring. For
// the uniform mode Lambda_c is the cavity volume and this is exactly the
// rho*c^2/V the lumped model always used.
StiffnessPaPerM3 float64 `json:"stiffnessPaPerM3"`
}
CavityMode describes one rigid-walled cylindrical air mode of the enclosed volume.
The family is deliberately restricted to the axially uniform modes, l = 0. Including axial order would mean pressure varying along the shell, and then the two heads no longer see the same pressure: the coupling coefficient would acquire a cos(l*pi*z/L) factor that is +1 at the batter and (-1)^l at the resonant head, which is a different and larger change than this one. The first axial mode also sits at c/2L = 858 Hz for the shipped 0.2 m depth, above the two transverse modes this exists to add. Axially uniform only is therefore a first cut rather than the complete cavity, and the uniform member of the family is exactly the single lumped compliance the model had before.
Shapes are Psi_mn(r,theta) = J_m(j'_mn r/a) * {cos m*theta, sin m*theta}, where j'_mn is the n-th zero of J_m' — the derivative, because a rigid wall carries zero normal velocity and so imposes a Neumann condition. That is not the condition the heads obey: their J_m(z_mn) = 0 is a clamped edge. The uniform mode is the m = 0, j' = 0 member and is written here with RadialOrder 0 to keep it distinct from (0,1), whose zero is the first *positive* root of J_0' at 3.8317.
func GenerateCavityModes ¶
func GenerateCavityModes(config PhysicalDrum) ([]CavityMode, error)
GenerateCavityModes builds the enclosed-air modal basis in frequency order.
Selection mirrors the head banks: candidates are ordered by frequency and admitted until the configured count is reached, an m > 0 mode costing two slots for its cosine and sine members. Cavity.ModeCount of 1 admits the uniform mode alone, which is the pre-transverse model exactly.
func (CavityMode) IsUniform ¶
func (c CavityMode) IsUniform() bool
IsUniform reports the single mode that reproduces the lumped compliance.
type Contact ¶
type Contact struct {
Model ContactModel `json:"model"`
StiffnessNPerMAlpha float64 `json:"stiffnessNPerMAlpha"`
Exponent float64 `json:"exponent"`
HysteresisSPerM float64 `json:"hysteresisSPerM"`
MaxDurationSeconds float64 `json:"maxDurationSeconds"`
}
Contact parameterizes the stick/head interaction.
StiffnessNPerMAlpha is the tip's contact stiffness at the reference hardness, in N/m^alpha — the units carry the exponent, which is why it cannot be read as a spring constant. Exponent is Hertz's alpha.
Exponent is fixed by measurement rather than assumed. A Hertzian contact time scales as v^(-(alpha-1)/(alpha+1)), and Wagner's Fig. 4.7 crescendo runs 7.5 ms at piano to 5.9 ms at forte; over the three- to fourfold striking velocity that spans, the implied alpha is 1.42 to 1.56. So the canonical spherical-contact 3/2 is not a convenient assumption here, it is what the measured velocity dependence says — which is worth stating plainly, because it means the prescribed model's velocity law is not discarded by this change but reproduced by it.
HysteresisSPerM is the Hunt-Crossley coefficient: the elastic force is scaled by (1 + h*compression rate), so the loss vanishes with the compression instead of stepping at impact and at separation the way a linear dashpot does. It sets the tip's restitution, and with it how much of the stick's energy is left to be caught by the returning head.
MaxDurationSeconds bounds how long one strike is tracked. It is not a contact time — the stick separates long before it — but the window inside which the head is allowed to rise back into the stick. Past it the player has lifted.
func DefaultContact ¶
func DefaultContact() Contact
DefaultContact returns the calibrated stick contact.
Model is ContactPrescribed, which is the shipped sound and its known defect, not a judgement that it is the better of the two. Switching the default is a change to how the instrument sounds and needs its own re-fit; see docs/physical-contact.md for what ContactHertzian measures against it.
type ContactMetrics ¶
type ContactMetrics struct {
FirstLobeSeconds float64
DwellSeconds float64
TouchCount int
PeakForceN float64
ImpulseNS float64
}
ContactMetrics reports what a strike's contact actually did, which under ContactHertzian is an output of the model rather than an input to it.
FirstLobeSeconds is the first uninterrupted touch and DwellSeconds spans the first touch to the last release, so the two differ exactly when the head returns and catches the stick again. Those are the two numbers Wagner's Fig. 4.7 plots separately, and comparing them is how this model is checked.
type ContactModel ¶
type ContactModel string
ContactModel selects how the strike force is produced.
const ( // ContactPrescribed writes a half-sine of the measured contact duration // into the force buffer at trigger time. The head never influences it. ContactPrescribed ContactModel = "prescribed" // ContactHertzian integrates the stick as a free mass against a // Hunt-Crossley contact spring, so the force follows from where the head // is. Duration, shape and re-contact are outputs rather than inputs. ContactHertzian ContactModel = "hertzian" )
type DoubleHead ¶
type DoubleHead struct {
// contains filtered or unexported fields
}
DoubleHead is the passive two-head, lumped-cavity, and bounded Berger-tension model. Its implicit-midpoint/discrete-gradient update conserves the complete linear plus nonlinear stored energy when all losses are zero and dissipates energy monotonically when head or cavity losses are enabled.
Trigger, Tick, Reset, and Render perform no allocations. Reconfigure validates and builds a complete replacement before installing it as one owner-goroutine operation; a rejected update leaves the current model untouched and a successful update resets all dynamic state.
func NewDoubleHead ¶
func NewDoubleHead(config PhysicalDrum) (*DoubleHead, error)
NewDoubleHead precomputes two independently tuned modal banks, the enclosed air's modal basis, and the overlap integrals that couple them.
The coupling coefficient is the overlap of a head mode shape against a cavity mode shape over the head's disc. Its azimuthal factor vanishes unless the two azimuthal orders match, which is the only reason this is affordable: a head mode couples to at most one cavity mode per radial order rather than to all of them. Against the uniform cavity mode the overlap collapses to the mode's signed swept area, which is why a one-mode cavity is the lumped compliance this model used to carry, exactly and not approximately.
With only the uniform mode retained, every m > 0 head mode has an identically zero coefficient and neither drives the air nor is driven by it — the property Head.AxisymmetricOnly rests on. That was never a fact about drums: a real cylindrical cavity has transverse modes, the j'_mn series, and those couple to m > 0 head modes with a coefficient that is not zero. Enabling them is what Cavity.ModeCount above 1 does. See docs/physical-cavity.md.
func (*DoubleHead) BatterMode ¶
func (d *DoubleHead) BatterMode(index int) (Mode, bool)
BatterMode returns immutable batter-head metadata by value.
func (*DoubleHead) BatterModeCount ¶
func (d *DoubleHead) BatterModeCount() int
BatterModeCount reports the retained batter-head oscillator count.
func (*DoubleHead) CavityBulkStiffnessPaPerM3 ¶
func (d *DoubleHead) CavityBulkStiffnessPaPerM3() float64
CavityBulkStiffnessPaPerM3 reports the fitted stiffness scale times rho*c²/V, or zero when coupling is off.
func (*DoubleHead) CavityMode ¶
func (d *DoubleHead) CavityMode(index int) (CavityMode, bool)
CavityMode returns immutable enclosed-air mode metadata by value.
func (*DoubleHead) CavityModeCount ¶
func (d *DoubleHead) CavityModeCount() int
CavityModeCount reports the retained enclosed-air pressure states.
func (*DoubleHead) CavityVolumeM3 ¶
func (d *DoubleHead) CavityVolumeM3() float64
CavityVolumeM3 reports the ideal cylindrical cavity volume.
func (*DoubleHead) Config ¶
func (d *DoubleHead) Config() PhysicalDrum
Config returns an independent copy of the active physical parameters.
func (*DoubleHead) CouplingChannelCount ¶
func (d *DoubleHead) CouplingChannelCount() int
CouplingChannelCount reports the retained potential channels beyond the uniform one the Berger law already carries.
func (*DoubleHead) CouplingCoefficientCount ¶
func (d *DoubleHead) CouplingCoefficientCount() int
CouplingCoefficientCount reports the retained quartic coefficients. Zero means the coupling is off and the model is the one that shipped.
func (*DoubleHead) CouplingDivergedSteps ¶
func (d *DoubleHead) CouplingDivergedSteps() uint64
CouplingDivergedSteps reports how many samples since the last Reset had their coupled fixed point abandoned and re-solved without the coupling. On any configuration the validator admits this stays zero; a non-zero count means the coefficient is too large for the step at the amplitude being played, and what was heard was the Berger-only law for those samples.
func (*DoubleHead) CouplingPumpModes ¶
func (d *DoubleHead) CouplingPumpModes() []int
CouplingPumpModes reports the batter-mode indices the channel set was built from, in selection order.
func (*DoubleHead) CouplingWorstForceHz ¶
func (d *DoubleHead) CouplingWorstForceHz() float64
CouplingWorstForceHz reports the highest frequency the retained cubic force can reach, measured on the table that was actually built rather than on the conservative bound Validate applies.
func (*DoubleHead) IsActive ¶
func (d *DoubleHead) IsActive() bool
IsActive reports whether contact is pending or stored energy exceeds either enabled head's inactivity threshold, and the release bound has not expired.
The bound is checked first and it is unconditional: the loss law is reachable from the product's own knobs, so the energy test alone does not terminate. See ReleaseBoundSeconds.
func (*DoubleHead) LastContact ¶
func (d *DoubleHead) LastContact() ContactMetrics
LastContact reports the measured duration, count and impulse of the most recently completed contact. It is populated only under ContactHertzian, where those are results rather than settings.
func (*DoubleHead) PulseSamples ¶
func (d *DoubleHead) PulseSamples() int
PulseSamples reports the window the most recent strike's contact acts over. Under ContactPrescribed that window is the force pulse; under ContactHertzian the force is an output of the model, so it is the interval inside which the stick may still be touched by the head.
func (*DoubleHead) Reconfigure ¶
func (d *DoubleHead) Reconfigure(config PhysicalDrum) error
Reconfigure safely applies tuning, shell-depth, air, coupling, loss, strike, pickup, and quality changes. Successful updates deliberately reset the tail.
func (*DoubleHead) ReferenceFrequencyResponse ¶
func (d *DoubleHead) ReferenceFrequencyResponse( frequencyHz float64, ) (FrequencyResponse, error)
ReferenceFrequencyResponse solves the diagonal modal system plus its modal cavity coupling in the frequency domain. This is the small-signal response linearized at zero displacement; nonlinear level-dependent behaviour is verified in the time domain.
Each cavity mode contributes an impedance
Z_c = K_c (jW)^2 / ((jW)^2 + lambda jW + omega_c^2),
which for the uniform mode's omega_c = 0 is the K jW/(jW + lambda) the lumped compliance always had. Eliminating the pressures leaves
(I + Z M) P = Z S, M_cb = sum_i C_ic C_ib / D_i, S_c = sum_i C_ic u_i,
a k x k complex system whose k = 1 case is the Sherman-Morrison form this replaced. Allocation here is deliberate: the routine is offline.
func (*DoubleHead) Render ¶
func (d *DoubleHead) Render(dst []float64)
Render writes the filtered batter-side microphone signal into dst.
func (*DoubleHead) Reset ¶
func (d *DoubleHead) Reset()
Reset silences both heads and the cavity and discards pending contact.
func (*DoubleHead) ResonantMode ¶
func (d *DoubleHead) ResonantMode(index int) (Mode, bool)
ResonantMode returns immutable resonant-head metadata by value.
func (*DoubleHead) ResonantModeCount ¶
func (d *DoubleHead) ResonantModeCount() int
ResonantModeCount reports the retained resonant-head oscillator count.
func (*DoubleHead) Tick ¶
func (d *DoubleHead) Tick() DoubleHeadOutput
Tick advances both modal banks and the cavity by one sample.
func (*DoubleHead) Trigger ¶
func (d *DoubleHead) Trigger(velocity01 float64) error
Trigger starts a finite batter-head contact.
type DoubleHeadOutput ¶
type DoubleHeadOutput struct {
BatterDisplacementM float64
BatterVelocityMPerS float64
ResonantDisplacementM float64
ResonantVelocityMPerS float64
BatterRawRadiated float64
ResonantRawRadiated float64
AttackRawRadiated float64
ContactForceN float64
RawRadiated float64
Radiated float64
CavityPressurePa float64
SweptVolumeM3 float64
BatterTensionIncreaseNPerM float64
ResonantTensionIncreaseNPerM float64
LinearHeadMechanicalEnergyJ float64
NonlinearPotentialEnergyJ float64
CouplingPotentialEnergyJ float64
NonlinearSolveIterations int
HeadMechanicalEnergyJ float64
CavityMechanicalEnergyJ float64
TotalMechanicalEnergyJ float64
}
DoubleHeadOutput exposes the two head pickups, their separate radiated contributions, the batter-side microphone signal, and the energy stored in the heads and enclosed air.
type FrequencyResponse ¶
type FrequencyResponse struct {
BatterVelocityMPerS complex128
ResonantVelocityMPerS complex128
BatterRawRadiated complex128
ResonantRawRadiated complex128
RawRadiated complex128
CavityPressurePa complex128
// CavityPressuresPa is the pressure of every retained cavity mode, in the
// order GenerateCavityModes returns them. CavityPressurePa above is its
// uniform member, kept as its own field because that is the one quantity
// the lumped model ever had.
CavityPressuresPa []complex128
}
FrequencyResponse is the continuous-time reduced-model response to one newton of sinusoidal force at the configured batter strike footprint. It is intended for offline validation, not the audio-rate path.
type Head ¶
type Head struct {
Enabled bool `json:"enabled"`
AxisymmetricOnly bool `json:"axisymmetricOnly"`
RadiusM float64 `json:"radiusM"`
SurfaceDensityKgPerM2 float64 `json:"surfaceDensityKgPerM2"`
TensionNPerM float64 `json:"tensionNPerM"`
TensionAsymmetry TensionAsymmetry `json:"tensionAsymmetry"`
BendingStiffnessNM float64 `json:"bendingStiffnessNM"`
Loss0PerSecond float64 `json:"loss0PerSecond"`
Loss1MPerSecond float64 `json:"loss1MPerSecond"`
Loss2M2PerSecond float64 `json:"loss2M2PerSecond"`
RadiationLossPerSecond float64 `json:"radiationLossPerSecond"`
ModeDecayCorrections []ModeDecayCorrection `json:"modeDecayCorrections,omitempty"`
FrequencyLimitFraction float64 `json:"frequencyLimitFraction"`
InactiveEnergyThresholdJ float64 `json:"inactiveEnergyThresholdJ"`
}
Head describes one circular membrane/plate.
The three loss coefficients form the structural decay law γ(k) = Loss0 + Loss1·k + Loss2·k², evaluated by ModalDecayRatePerSecond. The k¹ term is the one that expresses constant Q: with ω ≈ c·k on a membrane, Loss1 = ζ·c holds the fraction of critical damping ζ fixed across the mode series, which is the measured behaviour above the fundamental. Loss0 is a frequency-independent floor and Loss2 an excess high-frequency loss; neither can produce constant Q on its own, so both stay small.
AxisymmetricOnly retains only the modes the enclosed air can reach. On the resonant head that is free rather than approximate: nothing can excite a resonant mode the cavity does not couple to. The strike force reaches only batter modes, and the cavity — the sole path between the heads — couples through an overlap integral whose azimuthal factor is exactly zero unless the head mode's azimuthal order matches a cavity mode's. Their displacement, their strain contribution to the tension law and their stored energy are therefore all exactly zero for all time, so dropping them is bit-exact. On the batter head it would silence most of the instrument.
With a one-state cavity the reachable set is m = 0 alone and the field is literally "axisymmetric only". With transverse cavity modes it widens to the azimuthal orders they carry — see retainCavityReachable for why the field is widened rather than rejected.
type Mode ¶
type Mode struct {
AzimuthalOrder int `json:"azimuthalOrder"`
RadialOrder int `json:"radialOrder"`
Orientation Orientation `json:"orientation"`
BesselZero float64 `json:"besselZero"`
WavenumberPerM float64 `json:"wavenumberPerM"`
FrequencyHz float64 `json:"frequencyHz"`
AngularFrequency float64 `json:"angularFrequency"`
StructuralDecayPerSecond float64 `json:"structuralDecayPerSecond"`
RadiationDecayPerSecond float64 `json:"radiationDecayPerSecond"`
DecayCorrectionPerSecond float64 `json:"decayCorrectionPerSecond"`
DecayRatePerSecond float64 `json:"decayRatePerSecond"`
ModalMassKg float64 `json:"modalMassKg"`
StrikeAccelerationPerN float64 `json:"strikeAccelerationPerN"`
PickupShape float64 `json:"pickupShape"`
RadiationWeight float64 `json:"radiationWeight"`
RadiatingMomentM2 float64 `json:"radiatingMomentM2"`
RadiationDirectivity float64 `json:"radiationDirectivity"`
SweptAreaM2 float64 `json:"sweptAreaM2"`
}
Mode describes one retained circular-head oscillator.
Three of these fields are easy to confuse, so they are named for what they are rather than for where they are used:
- SweptAreaM2 is the signed *net* area the mode sweeps, and is exactly zero for every m > 0 mode. It is the cavity's coupling coefficient and nothing else.
- RadiatingMomentM2 is the far-field geometric factor: the exact Rayleigh integral of the mode shape against the observation direction. It equals SweptAreaM2 when the microphone is on axis and is non-zero for m > 0.
- PickupShape is the mode shape at a point on the head. It belongs to the near-field contact diagnostics and to strike weighting, and must not appear in a far-field weight.
The json tags exist for callers that display a mode bank — the WASM bridge and the offline tools. Nothing in analysis.Report marshals this type: it carries its own row struct with its own tags, which is what keeps the committed reference fixture independent of the field names here.
func GenerateDrumModes ¶
func GenerateDrumModes(config PhysicalDrum) ([]Mode, error)
GenerateDrumModes constructs the modal basis of the whole instrument: the batter head, and the resonant head when it is enabled, in the same order NewDoubleHead assembles them.
GenerateModes returns the batter head alone, which is what the single-head reference model wants and what most of the calibration tests measure. Offline tools that reason about the spectrum a listener hears want this instead: on a double-headed tom the resonant head carries half the partials, and reading only the batter's bank silently makes R.TUNE look like a parameter that changes nothing.
func GenerateModes ¶
func GenerateModes(config PhysicalDrum) ([]Mode, error)
GenerateModes constructs a frequency-ordered modal basis for the batter head, truncated by both the quality budget and the anti-alias frequency.
type ModeDecayCorrection ¶
type ModeDecayCorrection struct {
AzimuthalOrder int `json:"azimuthalOrder"`
RadialOrder int `json:"radialOrder"`
DecayRatePerSecond float64 `json:"decayRatePerSecond"`
}
ModeDecayCorrection adds a measured residual to the two-parameter loss law. A correction applies to both orientations of a non-axisymmetric mode.
type NonlinearCoupling ¶
type NonlinearCoupling struct {
Enabled bool `json:"enabled"`
CoefficientNPerM float64 `json:"coefficientNPerM"`
PumpCount int `json:"pumpCount"`
PumpMaxFrequencyHz float64 `json:"pumpMaxFrequencyHz"`
MaxCoefficients int `json:"maxCoefficients"`
AliasFraction float64 `json:"aliasFraction"`
}
NonlinearCoupling adds the part of the geometric nonlinearity the Berger law throws away: the mode-to-mode transfer.
Berger keeps the first moment of g = |grad w|^2 and nothing else, so the modal equations stay diagonal and no mode can transfer energy to any other at any amplitude. What it discards is the *second* moment, and adding channels back is what lets a loud hit deposit energy at frequencies nothing struck. The force is cubic and odd, so it reaches 3f_a, 2f_a +/- f_b and f_a +/- f_b +/- f_c and nothing even; a single pump therefore reaches only f and 3f, which is why PumpCount below 2 is rejected outright.
CoefficientNPerM is beta_tilde, the local quartic's coefficient. At the shipped bank beta*A = 7.0e5 N/m, against a material E*h/(2(1-nu^2)) of 6.5e5 N/m — agreement to 8 %, which is a coincidence worth recording and not a derivation: the local quartic is one bracket of the von Karman family and Berger the other, and the coefficient is fitted either way.
PumpCount and PumpMaxFrequencyHz choose the mode set P the channels are built from; MaxCoefficients bounds the retained table; AliasFraction bounds where the cubic force may place energy. See docs/physical-nonlinearity.md.
func DefaultNonlinearCoupling ¶
func DefaultNonlinearCoupling() NonlinearCoupling
DefaultNonlinearCoupling returns the shipped coupling settings.
type Nonlinearity ¶
type Nonlinearity struct {
Enabled bool `json:"enabled"`
BatterTensionCoefficientNPerM3 float64 `json:"batterTensionCoefficientNPerM3"`
ResonantTensionCoefficientNPerM3 float64 `json:"resonantTensionCoefficientNPerM3"`
MaximumTensionRatio float64 `json:"maximumTensionRatio"`
Coupling NonlinearCoupling `json:"coupling"`
}
Nonlinearity controls the Berger-style tension increase shared by every retained mode of each head. TensionCoefficientNPerM3 is the small-strain slope dT/dS, where S is the integral of the squared head gradient in m². MaximumTensionRatio caps the tension increase relative to each head's static tension so retained modes remain below Nyquist.
type Orientation ¶
type Orientation uint8
Orientation distinguishes the two degenerate members of an azimuthal mode.
const ( OrientationCosine Orientation = iota OrientationSine )
func (Orientation) MarshalJSON ¶
func (o Orientation) MarshalJSON() ([]byte, error)
MarshalJSON writes the orientation as "cos" or "sin" rather than as the underlying 0 or 1.
It follows analysis.ModeMetric, which has always rendered this field through String() into the committed reference fixture. Two spellings of one enum across two JSON surfaces would be a trap for anything reading both, and the numeric one carries no meaning to a reader.
func (Orientation) String ¶
func (o Orientation) String() string
func (*Orientation) UnmarshalJSON ¶
func (o *Orientation) UnmarshalJSON(data []byte) error
UnmarshalJSON accepts what MarshalJSON writes, and the bare number as well, so that anything written before this method existed still reads.
type Output ¶
type Output struct {
DisplacementM float64
VelocityMPerS float64
ContactForceN float64
RawRadiated float64
Radiated float64
MechanicalEnergyJ float64
}
Output exposes point-pickup head motion, the unfiltered modal radiation sum, and the filtered microphone signal separately.
type PhysicalDrum ¶
type PhysicalDrum struct {
Version int `json:"version"`
SampleRateHz float64 `json:"sampleRateHz"`
Quality Quality `json:"quality"`
ResonantModeLimit int `json:"resonantModeLimit"`
Batter Head `json:"batter"`
Resonant Head `json:"resonant"`
Strike Strike `json:"strike"`
Cavity Cavity `json:"cavity"`
Nonlinearity Nonlinearity `json:"nonlinearity"`
Attack Attack `json:"attack"`
Pickup Pickup `json:"pickup"`
}
PhysicalDrum is the versioned, serializable physical-model configuration.
Quality and ResonantModeLimit are two separate oscillator budgets and are meant to be: the tier sizes the batter head, and the second sizes whatever the reachability reduction leaves of the resonant head. They were one number until the cavity became modal, at which point widening the cavity basis silently quadrupled the resonant bank. See DefaultResonantModeLimit.
func DecodeConfig ¶
func DecodeConfig(data []byte) (PhysicalDrum, error)
DecodeConfig decodes the current schema, migrates supported older schemas, and validates every field.
func DefaultPhysicalDrum ¶
func DefaultPhysicalDrum() PhysicalDrum
DefaultPhysicalDrum returns a conservative 12-inch double-headed tom configuration.
func (PhysicalDrum) Validate ¶
func (d PhysicalDrum) Validate() error
Validate checks every persisted field.
type Pickup ¶
type Pickup struct {
Radius01 float64 `json:"radius01"`
AngleRad float64 `json:"angleRad"`
DistanceM float64 `json:"distanceM"`
NearFieldScale float64 `json:"nearFieldScale"`
HighpassHz float64 `json:"highpassHz"`
LowpassHz float64 `json:"lowpassHz"`
OutputGain float64 `json:"outputGain"`
}
Pickup places the microphone and sets the balance of the two mechanisms it hears. Radius01 and AngleRad locate it over the head and DistanceM sets its height, and between them they decide both the observation direction used by the far-field weight and how much of the non-propagating near field survives.
NearFieldScale sets how much of that near field the microphone picks up. It is fitted, not derived — the effective area of an evanescent patch is outside what this reduced model can compute — and it matters more than any other number here: in the far field a drum this size is nearly a monopole, so the scale sets how much of the m > 0 families reach the output at all.
It does *not* silence them at zero, which this comment used to claim. Measured on the shipped default with NearFieldScale = 0, the batter's own (1,1) still sits only 4.8 dB below the strongest peak — partly because the modal cavity (P9/M2) gives the m > 0 head modes a second path to the microphone that does not run through the near field at all. "Very nearly the axisymmetric modes alone" was true of the model before the cavity carried transverse modes.
type Quality ¶
type Quality string
Quality selects a maximum real-time modal-state budget.
func (Quality) ModeLimit ¶
ModeLimit returns the modal-oscillator budget one head selects from. Non-axisymmetric eigenmodes consume two slots (cosine and sine orientation).
This is the *batter* head's budget, and since P9/M2 only the batter head's. A head with AxisymmetricOnly set runs the same selection and then keeps only the modes the enclosed air can reach, and how many that leaves depends on the cavity rather than on this number: with a lumped cavity it is the 6 m = 0 modes out of Standard's 96, but with the shipped transverse basis the reachable set is {0,1,2} and the same selection leaves 28. So one number was sizing two banks that are excited by different things and want different sizes, and the size of the second was a side effect of a cavity setting. PhysicalDrum.ResonantModeLimit gives the reduced head its own; see DefaultResonantModeLimit for what sets it, and generateHeadModes for where it applies.
The tiers doubled when the resonant head stopped being computed and discarded. Bandwidth grows only as the square root of the count, because a membrane's mode count grows as f²: 48 to 96 slots moves the top retained mode from 646 Hz to about 914 Hz, which is 0.6 of an octave, not one. Reaching several kHz modally would need thousands of oscillators, which is why the attack layer exists.
type SingleHead ¶
type SingleHead struct {
// contains filtered or unexported fields
}
SingleHead is the P2 real-time modal prototype. It owns all working memory; Trigger, Tick, Reset, and Render perform no allocations.
func NewSingleHead ¶
func NewSingleHead(config PhysicalDrum) (*SingleHead, error)
NewSingleHead precomputes the circular modes, exact damped state-transition matrices, strike projection, pickup weights, and maximum contact storage.
func (*SingleHead) IsActive ¶
func (s *SingleHead) IsActive() bool
IsActive reports whether contact is pending or mechanical energy is above the configured threshold, and the release bound has not expired.
SingleHead is an offline reference rather than a product voice, so the bound cannot be reached from a knob here. It carries it anyway so that the two models answer the same question the same way; see ReleaseBoundSeconds.
func (*SingleHead) LastContact ¶
func (s *SingleHead) LastContact() ContactMetrics
LastContact reports the measured duration, count and impulse of the most recently completed contact. It is populated only under ContactHertzian, where those are results rather than settings.
func (*SingleHead) Mode ¶
func (s *SingleHead) Mode(index int) (Mode, bool)
Mode returns immutable metadata by value.
func (*SingleHead) ModeCount ¶
func (s *SingleHead) ModeCount() int
ModeCount reports the retained individual oscillator count.
func (*SingleHead) PulseSamples ¶
func (s *SingleHead) PulseSamples() int
PulseSamples reports the window the most recent strike's contact acts over. Under ContactPrescribed that window is the force pulse; under ContactHertzian the force is an output of the model, so it is the interval inside which the stick may still be touched by the head. See ContactMetrics for what the contact then actually did.
func (*SingleHead) Render ¶
func (s *SingleHead) Render(dst []float64)
Render writes the filtered radiated microphone signal into dst.
func (*SingleHead) Reset ¶
func (s *SingleHead) Reset()
Reset silences the head and discards any pending contact.
func (*SingleHead) Tick ¶
func (s *SingleHead) Tick() Output
Tick advances the exact linear modal state by one sample.
func (*SingleHead) Trigger ¶
func (s *SingleHead) Trigger(velocity01 float64) error
Trigger starts a finite, velocity- and hardness-dependent contact. Existing modal motion is retained, so closely spaced hits superpose instead of restarting the drum.
type Strike ¶
type Strike struct {
Radius01 float64 `json:"radius01"`
AngleRad float64 `json:"angleRad"`
ContactRadiusM float64 `json:"contactRadiusM"`
MalletMassKg float64 `json:"malletMassKg"`
VelocityMPerS float64 `json:"velocityMPerS"`
Hardness01 float64 `json:"hardness01"`
Contact Contact `json:"contact"`
}
Strike describes the mallet and its finite contact footprint.
type TensionAsymmetry ¶
type TensionAsymmetry struct {
SplitRatio float64 `json:"splitRatio"`
PrincipalAxisAngleRad float64 `json:"principalAxisAngleRad"`
}
TensionAsymmetry is a reduced, deterministic representation of non-uniform rim tension. SplitRatio is the full relative frequency separation of each non-axisymmetric cosine/sine pair around its ideal circular-head frequency. PrincipalAxisAngleRad rotates the pair's mode shapes into the measured or deliberately selected tension axis. Axisymmetric modes are unchanged.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package analysis provides offline calibration and regression measurements for the real-time physical drum model.
|
Package analysis provides offline calibration and regression measurements for the real-time physical drum model. |
|
Package match turns a drum hit — recorded or rendered — into a small set of perceptual features, and scores two of them against each other.
|
Package match turns a drum hit — recorded or rendered — into a small set of perceptual features, and scores two of them against each other. |
|
Package series relates the measurements of one take to the measurements of the others.
|
Package series relates the measurements of one take to the measurements of the others. |