Skip to content

Commit 95689c2

Browse files
committed
Implement all 12 SFX pipeline improvements from the guide
Waveform fixes: - Fix broken SAW_TOOTH, TRIANGLE, PULSE in Instrument.generate() using correct phase-based math (matching Oscillator.emit()) - Replace per-sample Random allocation in Oscillator noise with persistent Random instance Envelope improvements: - Switch ADSR from linear to quadratic curves for natural sound (x^2 attack, inverse-square decay/release) - Add minimum 2ms release (88 samples) to prevent clicks - Add safety fade-out at buffer boundaries in SoundManager New effects: - Add Tremolo class (amplitude modulation LFO) in Effect.kt - Add configurable dutyCycle field to Instrument for PULSE wave - Wire dutyCycle through Oscillator and InstrumentPlayer Mixing improvements: - Replace all hard clipping with tanh-based soft saturation - Add softClip() to SoundManager companion, used in SoundManager, JavaSoundManager, and MixerGateway - Add harmonics normalization inside Harmonizer.generate() - Make master volume a configurable instance var on SoundManager DC offset handling: - Subtract analytical DC offset from PULSE waves - Add single-pole high-pass DC blocker for NOISE waves Documentation: - Fix Harmonizer KDoc (index 0 = fundamental, not 2nd harmonic) Tests updated for new quadratic envelope curves, harmonizer normalization, and click prevention behavior. https://claude.ai/code/session_01XDAnX2yoseGsbSS2YGCirn
1 parent cf85471 commit 95689c2

File tree

11 files changed

+329
-140
lines changed

11 files changed

+329
-140
lines changed

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/sound/Effect.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,30 @@ class Vibrato(
5959
return frequency + vibrato
6060
}
6161
}
62+
63+
/**
64+
* Tremolo effect — amplitude modulation using a low-frequency oscillator (LFO).
65+
*
66+
* Unlike [Modulation] which modulates frequency, tremolo modulates the amplitude
67+
* of the signal. Typical LFO rates are 2–10 Hz.
68+
*
69+
* @param frequency LFO rate in Hz
70+
* @param depth Modulation depth: 0.0 = no effect, 1.0 = full tremolo
71+
*/
72+
@Serializable
73+
class Tremolo(
74+
var frequency: Frequency = 0f,
75+
var depth: Percent = 0f,
76+
) {
77+
var active: Boolean = false
78+
79+
fun apply(
80+
time: Seconds,
81+
sample: Float,
82+
): Float {
83+
if (!active || depth == 0f) return sample
84+
// LFO oscillates between (1-depth) and 1.0
85+
val lfo = (1.0f - depth) + depth * ((sin(TWO_PI * frequency * time) + 1.0f) * 0.5f)
86+
return sample * lfo
87+
}
88+
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/sound/Envelop.kt

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.github.minigdx.tiny.sound
22

33
import com.github.minigdx.tiny.Percent
44
import com.github.minigdx.tiny.Sample
5+
import kotlin.math.max
56

67
class Envelop(
78
internal val attack0: () -> Sample,
@@ -13,6 +14,10 @@ class Envelop(
1314
* Return the multiplier to apply to a sample value regarding the progression of the sound.
1415
* The [noteOn] phase will apply the [attack] then the [decay] then the [sustain]
1516
* and keep the [sustain] until the [noteOff].
17+
*
18+
* Uses quadratic curves for more natural-sounding envelopes:
19+
* - Attack uses x^2 (slow start, fast finish)
20+
* - Decay uses inverse-square (fast drop, slow tail toward sustain)
1621
*/
1722
fun noteOn(progress: Sample): Percent {
1823
val attack = attack0.invoke()
@@ -22,21 +27,23 @@ class Envelop(
2227
return when {
2328
progress < 0 -> 0.0f
2429
progress <= attack -> {
25-
// Attack phase: 0.0 to 1.0 over attack samples
30+
// Attack phase: 0.0 to 1.0 with quadratic curve (slow start, fast finish)
2631
if (attack == 0) {
2732
1.0f
2833
} else {
29-
progress.toFloat() / attack.toFloat()
34+
val linear = progress.toFloat() / attack.toFloat()
35+
linear * linear
3036
}
3137
}
3238
progress <= attack + decay -> {
33-
// Decay phase: 1.0 to sustain over decay samples
39+
// Decay phase: 1.0 to sustain with inverse-square curve (fast drop, slow tail)
3440
if (decay == 0) {
3541
sustain
3642
} else {
3743
val decayProgress = progress - attack
38-
val decayAmount = 1.0f - sustain
39-
1.0f - (decayAmount * decayProgress.toFloat() / decay.toFloat())
44+
val linear = decayProgress.toFloat() / decay.toFloat()
45+
val remaining = 1.0f - linear
46+
sustain + (1.0f - sustain) * remaining * remaining
4047
}
4148
}
4249
else -> {
@@ -49,24 +56,33 @@ class Envelop(
4956
/**
5057
* Return the multiplier to apply to a sample value regarding the progression of the sound.
5158
* The [noteOff] will apply the [attack] then the [decay] then right away the [release].
59+
*
60+
* Enforces a minimum release duration of ~2ms (88 samples at 44100 Hz)
61+
* to prevent audible clicks from abrupt note endings.
62+
*
63+
* Uses quadratic curve (fast drop, slow tail) for natural release.
5264
*/
5365
fun noteOff(progress: Sample): Percent {
5466
val sustain = sustain0.invoke()
55-
val release = release0.invoke()
67+
val release = max(MIN_RELEASE_SAMPLES, release0.invoke())
5668

5769
return when {
70+
progress < 0 -> 0.0f
5871
progress <= release -> {
59-
// Release phase: sustain to 0.0 over release samples
60-
if (release == 0 || progress < 0) {
61-
0.0f
62-
} else {
63-
sustain * (1.0f - progress.toFloat() / release.toFloat())
64-
}
72+
// Release phase: sustain to 0.0 with quadratic curve (fast drop, slow tail)
73+
val linear = progress.toFloat() / release.toFloat()
74+
val remaining = 1.0f - linear
75+
sustain * remaining * remaining
6576
}
6677
else -> {
6778
// After release: silence
6879
0.0f
6980
}
7081
}
7182
}
83+
84+
companion object {
85+
// ~2ms at 44100 Hz - minimum release to prevent clicks
86+
const val MIN_RELEASE_SAMPLES = 88
87+
}
7288
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/sound/Harmonizer.kt

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import com.github.minigdx.tiny.lua.Note
99
* with the fundamental frequency of a note. This creates richer, more complex sounds
1010
* than simple sine waves.
1111
*
12-
* Each harmonic is a multiple of the fundamental frequency (2x, 3x, 4x, etc.) and
12+
* Each harmonic is a multiple of the fundamental frequency (1x, 2x, 3x, etc.) and
1313
* has its own amplitude weight defined in the harmonics array.
1414
*
15-
* @param harmonics Array of relative amplitudes for each harmonic. Index 0 represents
16-
* the first harmonic (2x fundamental), index 1 represents the second
17-
* harmonic (3x fundamental), etc. Values typically range from 0.0 to 1.0.
15+
* The output is normalized so that the sum of harmonics never exceeds [-1, 1],
16+
* preventing clipping in downstream stages.
17+
*
18+
* @param harmonics0 Array of relative amplitudes for each harmonic. Index 0 represents
19+
* the fundamental (1x frequency), index 1 represents the 2nd harmonic
20+
* (2x frequency), index 2 represents the 3rd harmonic (3x frequency), etc.
21+
* Values typically range from 0.0 to 1.0.
1822
*/
1923
class Harmonizer(
2024
val harmonics0: () -> FloatArray,
@@ -24,11 +28,13 @@ class Harmonizer(
2428
* Each harmonic frequency is calculated as a multiple of the fundamental frequency,
2529
* and the generator function is called to produce the actual waveform value for each frequency.
2630
*
31+
* The result is normalized by the total harmonic amplitude to prevent exceeding [-1, 1].
32+
*
2733
* @param note The musical note that provides the fundamental frequency
2834
* @param sample The current sample number (used for time-based calculations)
29-
* @param generator A function that generates waveform values given a frequency and harmonic number.
30-
* Takes (frequency, harmonicNumber) and returns the waveform sample value.
31-
* @return The combined sample value of the fundamental frequency and all its harmonics
35+
* @param generator A function that generates waveform values given a frequency and sample index.
36+
* Takes (frequency, sampleIndex) and returns the waveform sample value.
37+
* @return The combined and normalized sample value
3238
*/
3339
fun generate(
3440
note: Note,
@@ -39,16 +45,18 @@ class Harmonizer(
3945

4046
val harmonics = harmonics0.invoke()
4147
var sampleValue = 0f
48+
var totalAmplitude = 0f
4249
harmonics.forEachIndexed { index, relativeAmplitude ->
43-
// Harmonic numbers start at 1 (fundamental is implied to be 1x)
44-
// So index 0 = 2nd harmonic (2x), index 1 = 3rd harmonic (3x), etc.
4550
val harmonicNumber = index + 1
4651
val harmonicFreq = fundamentalFreq * harmonicNumber
4752
val value = generator.invoke(harmonicFreq, sample)
4853

49-
// Weight the harmonic contribution by its relative amplitude
5054
sampleValue += relativeAmplitude * value
55+
totalAmplitude += relativeAmplitude
5156
}
52-
return sampleValue
57+
58+
// Normalize to prevent exceeding [-1, 1]
59+
val normFactor = if (totalAmplitude > 1.0f) 1.0f / totalAmplitude else 1.0f
60+
return sampleValue * normFactor
5361
}
5462
}

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/sound/Instrument.kt

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package com.github.minigdx.tiny.sound
22

33
import com.github.minigdx.tiny.Percent
44
import com.github.minigdx.tiny.Seconds
5-
import com.github.minigdx.tiny.lua.Note
65
import com.github.minigdx.tiny.sound.Instrument.WaveType.NOISE
76
import com.github.minigdx.tiny.sound.Instrument.WaveType.PULSE
87
import com.github.minigdx.tiny.sound.Instrument.WaveType.SAW_TOOTH
@@ -13,7 +12,6 @@ import com.github.minigdx.tiny.sound.SoundManager.Companion.SAMPLE_RATE
1312
import kotlinx.serialization.Serializable
1413
import kotlinx.serialization.Transient
1514
import kotlin.math.PI
16-
import kotlin.math.abs
1715
import kotlin.math.exp
1816
import kotlin.math.max
1917
import kotlin.math.sin
@@ -61,9 +59,18 @@ class Instrument(
6159
* Will be applied in the order configured.
6260
*/
6361
val modulations: List<Modulation> = listOf(
64-
Sweep(Note.A5.frequency, 1f),
62+
Sweep(440f, 1f),
6563
Vibrato(0f, 0f),
6664
),
65+
/**
66+
* Duty cycle for the PULSE wave type.
67+
* 0.5 = square wave, 0.25 = nasal, 0.125 = thin/reedy.
68+
*/
69+
var dutyCycle: Percent = 0.5f,
70+
/**
71+
* Tremolo effect (amplitude modulation).
72+
*/
73+
val tremolo: Tremolo = Tremolo(),
6774
) {
6875
enum class WaveType {
6976
SAW_TOOTH,
@@ -74,7 +81,7 @@ class Instrument(
7481
SQUARE,
7582
}
7683

77-
// Last output generated. Used by the [NOISE] wave type
84+
// State for NOISE wave type - low-pass filter
7885
@Transient
7986
private var lastOutput: Float = 0.0f
8087

@@ -84,6 +91,16 @@ class Instrument(
8491
@Transient
8592
private var cachedAlpha: Float = 0.0f
8693

94+
@Transient
95+
private val random: Random = Random(42)
96+
97+
// DC blocker state for NOISE
98+
@Transient
99+
private var dcBlockerPrev: Float = 0f
100+
101+
@Transient
102+
private var dcBlockerOut: Float = 0f
103+
87104
fun generate(
88105
freq: Float,
89106
time: Float,
@@ -92,36 +109,35 @@ class Instrument(
92109
val harmonicFreq = modulations.filter { it.active }
93110
.fold(freq) { acc, modulation -> modulation.apply(time, acc) }
94111

95-
return when (this.wave) {
112+
val sample = when (this.wave) {
96113
TRIANGLE -> {
97-
val angle: Float = sin(TWO_PI * harmonicFreq * time)
98-
val phase = (angle + 1.0) % 1.0 // Normalize sinValue to the range [0, 1]
99-
return (if (phase < 0.5) 4.0 * phase - 1.0 else 3.0 - 4.0 * phase).toFloat()
114+
val phase = (harmonicFreq * time) % 1.0f
115+
if (phase < 0.5f) {
116+
(4.0f * phase) - 1.0f
117+
} else {
118+
3.0f - (4.0f * phase)
119+
}
100120
}
101121

102122
SINE -> sin(TWO_PI * harmonicFreq * time)
123+
103124
SQUARE -> {
104125
val value = sin(TWO_PI * harmonicFreq * time)
105-
return if (value > 0f) {
106-
1f
107-
} else {
108-
-1f
109-
}
126+
if (value > 0f) 1f else -1f
110127
}
111128

112129
PULSE -> {
113-
val angle = sin(TWO_PI * harmonicFreq * time)
114-
115-
val t = angle % 1
116-
val k = abs(2.0 * ((angle / 128.0) % 1.0) - 1.0)
117-
val u = (t + 0.5 * k) % 1.0
118-
val ret = abs(4.0 * u - 2.0) - abs(8.0 * t - 4.0)
119-
return (ret / 6.0).toFloat()
130+
val phase = (harmonicFreq * time) % 1.0f
131+
val dc = dutyCycle
132+
val raw = if (phase < dc) 1.0f else -1.0f
133+
// Remove DC offset for non-50% duty cycles
134+
val dcOffset = (2.0f * dc) - 1.0f
135+
raw - dcOffset
120136
}
121137

122138
SAW_TOOTH -> {
123-
val angle: Float = sin(TWO_PI * harmonicFreq * time)
124-
return (angle * 2f) - 1f
139+
val phase = (harmonicFreq * time) % 1.0f
140+
(2.0f * phase) - 1.0f
125141
}
126142

127143
NOISE -> {
@@ -132,18 +148,23 @@ class Instrument(
132148
val safeCutoff = max(1f, harmonicFreq)
133149
val wc = TWO_PI * safeCutoff / SAMPLE_RATE
134150
val x = exp(-wc)
135-
// Cache values
136151
cachedAlpha = 1.0f - x
137152
lastFrequencyUsed = harmonicFreq
138-
139153
cachedAlpha
140154
}
141-
val white = Random.nextFloat() * 2f - 1f
142-
val result = alpha * white + (1.0f - alpha) * lastOutput
143-
lastOutput = result
144-
return result
155+
val white = random.nextFloat() * 2f - 1f
156+
val filtered = alpha * white + (1.0f - alpha) * lastOutput
157+
lastOutput = filtered
158+
159+
// DC blocker (single-pole high-pass, ~20 Hz cutoff)
160+
val dcAlpha = 0.997f
161+
dcBlockerOut = dcAlpha * (dcBlockerOut + filtered - dcBlockerPrev)
162+
dcBlockerPrev = filtered
163+
dcBlockerOut
145164
}
146165
}
166+
167+
return tremolo.apply(time, sample)
147168
}
148169

149170
companion object {

tiny-engine/src/commonMain/kotlin/com/github/minigdx/tiny/sound/InstrumentPlayer.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ class InstrumentPlayer(private val instrument: Instrument) {
3939

4040
private val harmonizer = Harmonizer({ instrument.harmonics })
4141

42-
private val oscillator = Oscillator({ instrument.wave })
42+
private val oscillator = Oscillator(
43+
waveType0 = { instrument.wave },
44+
dutyCycle0 = { instrument.dutyCycle },
45+
)
4346

4447
private val notesOn = mutableSetOf<NoteProgress>()
4548
private val notesOff = mutableSetOf<NoteProgress>()

0 commit comments

Comments
 (0)