Skip to content

Commit 23c56c7

Browse files
dwursteisenclaude
andcommitted
add DRUM wave type with note-based drum synthesis
- Add DrumSynthesizer object that maps pitch class to drum part (C=bass, D=snare, E=hi-hat closed, F=hi-hat open, G=crash, A=tom1, B=tom2) - Add DRUM entry to WaveType enum in Instrument and Oscillator - Update drum preset to use DRUM wave type with pass-through ADSR - Add bounded output, differentiation, and decay tests for DRUM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b5dda91 commit 23c56c7

File tree

6 files changed

+215
-5
lines changed

6 files changed

+215
-5
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package com.github.minigdx.tiny.sound
2+
3+
import kotlin.math.PI
4+
import kotlin.math.exp
5+
import kotlin.math.ln
6+
import kotlin.math.roundToInt
7+
import kotlin.math.sin
8+
import kotlin.random.Random
9+
10+
/**
11+
* Stateless drum synthesizer that generates different drum sounds
12+
* based on the pitch class derived from the input frequency.
13+
*
14+
* Note-to-drum mapping:
15+
* - C (pitch class 0) = bass drum
16+
* - D (pitch class 2) = snare
17+
* - E (pitch class 4) = hi-hat closed
18+
* - F (pitch class 5) = hi-hat open
19+
* - G (pitch class 7) = crash cymbal
20+
* - A (pitch class 9) = tom 1
21+
* - B (pitch class 11) = tom 2
22+
*
23+
* Sharps/flats map to the nearest natural note.
24+
*/
25+
object DrumSynthesizer {
26+
private const val TWO_PI = PI.toFloat() * 2f
27+
private const val C0_FREQ = 16.3516f
28+
29+
/**
30+
* Determines which drum part to play from the pitch class of [freq],
31+
* then synthesises one sample at the given [time] (in seconds).
32+
*
33+
* @param freq The note frequency in Hz (used to detect pitch class).
34+
* @param time Elapsed time in seconds since note-on.
35+
* @param random A [Random] instance for noise generation.
36+
* @return A sample value clamped to [-1, 1].
37+
*/
38+
fun generate(
39+
freq: Float,
40+
time: Float,
41+
random: Random,
42+
): Float {
43+
val drumPart = pitchClassToDrum(freq)
44+
return when (drumPart) {
45+
DrumPart.BASS -> bassDrum(time)
46+
DrumPart.SNARE -> snare(time, random)
47+
DrumPart.HIHAT_CLOSED -> hihatClosed(time, random)
48+
DrumPart.HIHAT_OPEN -> hihatOpen(time, random)
49+
DrumPart.CRASH -> crash(time, random)
50+
DrumPart.TOM1 -> tom1(time)
51+
DrumPart.TOM2 -> tom2(time)
52+
}.coerceIn(-1f, 1f)
53+
}
54+
55+
private enum class DrumPart {
56+
BASS,
57+
SNARE,
58+
HIHAT_CLOSED,
59+
HIHAT_OPEN,
60+
CRASH,
61+
TOM1,
62+
TOM2,
63+
}
64+
65+
/**
66+
* Derives the pitch class (0..11) from a frequency and maps it
67+
* to the nearest natural note drum part.
68+
*/
69+
private fun pitchClassToDrum(freq: Float): DrumPart {
70+
if (freq <= 0f) return DrumPart.BASS
71+
val semitones = 12f * (ln(freq / C0_FREQ) / ln(2f))
72+
val pitchClass = ((semitones.roundToInt() % 12) + 12) % 12
73+
return when (pitchClass) {
74+
0, 1 -> DrumPart.BASS // C, C#
75+
2, 3 -> DrumPart.SNARE // D, D#
76+
4 -> DrumPart.HIHAT_CLOSED // E
77+
5, 6 -> DrumPart.HIHAT_OPEN // F, F#
78+
7, 8 -> DrumPart.CRASH // G, G#
79+
9, 10 -> DrumPart.TOM1 // A, A#
80+
11 -> DrumPart.TOM2 // B
81+
else -> DrumPart.BASS
82+
}
83+
}
84+
85+
// ---- Individual drum synthesis functions ----
86+
87+
/** Bass drum: sine with pitch sweep 150->50 Hz + click transient, fast decay. */
88+
private fun bassDrum(time: Float): Float {
89+
val decay = exp(-time * 15f)
90+
val sweepFreq = 50f + 100f * exp(-time * 40f)
91+
val body = sin(TWO_PI * sweepFreq * time) * decay
92+
val click = exp(-time * 200f) * 0.8f
93+
return body + click
94+
}
95+
96+
/** Snare: 30% sine body at 180 Hz + 70% white noise, medium-fast decay. */
97+
private fun snare(
98+
time: Float,
99+
random: Random,
100+
): Float {
101+
val bodyDecay = exp(-time * 20f)
102+
val noiseDecay = exp(-time * 15f)
103+
val body = sin(TWO_PI * 180f * time) * bodyDecay * 0.3f
104+
val noise = (random.nextFloat() * 2f - 1f) * noiseDecay * 0.7f
105+
return body + noise
106+
}
107+
108+
/** Hi-hat closed: metallic inharmonic tones + noise, very short decay (~17ms). */
109+
private fun hihatClosed(
110+
time: Float,
111+
random: Random,
112+
): Float {
113+
val decay = exp(-time * 60f) // ~17ms effective duration
114+
val metallic = sin(TWO_PI * 3527f * time) * sin(TWO_PI * 4735f * time)
115+
val noise = random.nextFloat() * 2f - 1f
116+
return (metallic * 0.6f + noise * 0.4f) * decay
117+
}
118+
119+
/** Hi-hat open: same metallic character, longer decay (~125ms). */
120+
private fun hihatOpen(
121+
time: Float,
122+
random: Random,
123+
): Float {
124+
val decay = exp(-time * 8f) // ~125ms effective duration
125+
val metallic = sin(TWO_PI * 3527f * time) * sin(TWO_PI * 4735f * time)
126+
val noise = random.nextFloat() * 2f - 1f
127+
return (metallic * 0.6f + noise * 0.4f) * decay
128+
}
129+
130+
/** Crash cymbal: three metallic frequencies + dominant noise, long decay. */
131+
private fun crash(
132+
time: Float,
133+
random: Random,
134+
): Float {
135+
val decay = exp(-time * 3f)
136+
val m1 = sin(TWO_PI * 4200f * time)
137+
val m2 = sin(TWO_PI * 5386f * time)
138+
val m3 = sin(TWO_PI * 3750f * time)
139+
val metallic = (m1 + m2 + m3) / 3f
140+
val noise = random.nextFloat() * 2f - 1f
141+
return (metallic * 0.3f + noise * 0.7f) * decay
142+
}
143+
144+
/** Tom 1: sine with pitch sweep 200->120 Hz, medium decay. */
145+
private fun tom1(time: Float): Float {
146+
val decay = exp(-time * 10f)
147+
val sweepFreq = 120f + 80f * exp(-time * 25f)
148+
return sin(TWO_PI * sweepFreq * time) * decay
149+
}
150+
151+
/** Tom 2: sine with pitch sweep 150->80 Hz, slightly longer decay. */
152+
private fun tom2(time: Float): Float {
153+
val decay = exp(-time * 8f)
154+
val sweepFreq = 80f + 70f * exp(-time * 20f)
155+
return sin(TWO_PI * sweepFreq * time) * decay
156+
}
157+
}

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

Lines changed: 4 additions & 0 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.Seconds
5+
import com.github.minigdx.tiny.sound.Instrument.WaveType.DRUM
56
import com.github.minigdx.tiny.sound.Instrument.WaveType.NOISE
67
import com.github.minigdx.tiny.sound.Instrument.WaveType.PULSE
78
import com.github.minigdx.tiny.sound.Instrument.WaveType.SAW_TOOTH
@@ -79,6 +80,7 @@ class Instrument(
7980
SINE,
8081
NOISE,
8182
SQUARE,
83+
DRUM,
8284
}
8385

8486
// State for NOISE wave type - low-pass filter
@@ -162,6 +164,8 @@ class Instrument(
162164
dcBlockerPrev = filtered
163165
dcBlockerOut
164166
}
167+
168+
DRUM -> DrumSynthesizer.generate(harmonicFreq, time, random)
165169
}
166170

167171
return tremolo.apply(time, sample)

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ val drum =
9393
Instrument(
9494
index = 3,
9595
name = "drum",
96-
wave = Instrument.WaveType.NOISE,
97-
attack = 0.1f,
98-
decay = 0.1f,
99-
sustain = 0.9f,
96+
wave = Instrument.WaveType.DRUM,
97+
attack = 0.001f,
98+
decay = 0.01f,
99+
sustain = 1.0f,
100100
release = 0.05f,
101101
harmonics = floatArrayOf(1f),
102102
)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ class Oscillator(
104104
dcBlockerPrev = filtered
105105
dcBlockerOut
106106
}
107+
108+
Instrument.WaveType.DRUM -> DrumSynthesizer.generate(frequency, time, random)
107109
}
108110
}
109111

tiny-engine/src/commonTest/kotlin/com/github/minigdx/tiny/sound/OscillatorTest.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,52 @@ class OscillatorTest {
184184
assertEquals(result1, result2, 0.001f, "Same inputs should produce same outputs")
185185
}
186186

187+
@Test
188+
fun emit_drum_bass_snare_hihat_produce_bounded_output() {
189+
val oscillator = Oscillator(waveType0 = { Instrument.WaveType.DRUM })
190+
// C4 = ~261.63 Hz (bass drum), D4 = ~293.66 Hz (snare), E4 = ~329.63 Hz (hi-hat closed)
191+
val frequencies = listOf(261.63f, 293.66f, 329.63f)
192+
for (freq in frequencies) {
193+
for (sample in 0..2000 step 10) {
194+
val result = oscillator.emit(freq, sample)
195+
assertTrue(
196+
result >= -1.0f && result <= 1.0f,
197+
"Drum at freq $freq should be bounded [-1, 1], got $result at sample $sample",
198+
)
199+
}
200+
}
201+
}
202+
203+
@Test
204+
fun emit_drum_different_notes_produce_different_sounds() {
205+
// C4 (bass drum) vs D4 (snare) should produce different waveforms
206+
val oscBass = Oscillator(waveType0 = { Instrument.WaveType.DRUM })
207+
val oscSnare = Oscillator(waveType0 = { Instrument.WaveType.DRUM })
208+
209+
val bassSamples = (0..100).map { oscBass.emit(261.63f, it) }
210+
val snareSamples = (0..100).map { oscSnare.emit(293.66f, it) }
211+
212+
// The two drum parts should not produce identical output
213+
val different = bassSamples.zip(snareSamples).any { (a, b) -> abs(a - b) > 0.01f }
214+
assertTrue(different, "Bass drum and snare should produce different sounds")
215+
}
216+
217+
@Test
218+
fun emit_drum_sound_decays_over_time() {
219+
val oscillator = Oscillator(waveType0 = { Instrument.WaveType.DRUM })
220+
// Use C4 (bass drum) - should decay
221+
val earlyEnergy = (0..50).map { abs(oscillator.emit(261.63f, it)) }.average()
222+
223+
val oscillator2 = Oscillator(waveType0 = { Instrument.WaveType.DRUM })
224+
// Late samples - well after the drum has decayed
225+
val lateEnergy = (20000..20050).map { abs(oscillator2.emit(261.63f, it)) }.average()
226+
227+
assertTrue(
228+
earlyEnergy > lateEnergy,
229+
"Drum sound should decay over time: early=$earlyEnergy, late=$lateEnergy",
230+
)
231+
}
232+
187233
@Test
188234
fun emit_all_wave_types_produce_bounded_output() {
189235
val waveTypes = listOf(
@@ -193,6 +239,7 @@ class OscillatorTest {
193239
Instrument.WaveType.SAW_TOOTH,
194240
Instrument.WaveType.PULSE,
195241
Instrument.WaveType.NOISE,
242+
Instrument.WaveType.DRUM,
196243
)
197244

198245
val frequency = 440.0f

tiny-engine/src/jvmMain/kotlin/com/github/minigdx/tiny/sound/MixerGateway.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package com.github.minigdx.tiny.sound
22

3+
import com.github.minigdx.tiny.sound.SoundManager.Companion.softClip
34
import java.util.concurrent.BlockingQueue
45
import java.util.concurrent.ConcurrentLinkedQueue
56
import javax.sound.sampled.AudioFormat
67
import javax.sound.sampled.AudioSystem
78
import javax.sound.sampled.DataLine
89
import javax.sound.sampled.SourceDataLine
9-
import com.github.minigdx.tiny.sound.SoundManager.Companion.softClip
1010
import kotlin.math.roundToInt
1111

1212
/**

0 commit comments

Comments
 (0)