StruckShapes
shapes you can hit, and they sound like the shape they are: an outline's own standing waves decide the frequencies, and where you strike decides which ones you hear (StruckShape)
- Run
swift run --package-path Examples Example-Audio-StruckShapes- Imports
OllinAudio- Source
- Examples/Audio/StruckShapes/Sketch.swift
import Ollin
import OllinAudio
/// Shapes you can hit, that sound like the shape they are.
///
/// A flat thing held at its edge rings at frequencies decided entirely by its
/// outline. Working them out is a matter of asking which standing waves fit
/// inside the shape, which is what `StruckShape` does: measure the outline
/// once, then strike it anywhere.
///
/// Nothing here chooses a sound. The round one comes out with the ratios a real
/// drumhead has, the square one with a square membrane's, and the blob with
/// whatever its own outline implies. The bars under each shape are those
/// ratios, drawn where they fall.
///
/// Click a shape to strike it where you clicked. Where you hit it decides which
/// of its tones answer, because a tone that holds still under your finger gets
/// nothing, which is why the bars move when you move the click.
@main
final class StruckShapes: Sketch {
@Param(0.2 ... 8, icon: "clock", group: "Body") var ring = 2.6
@Param(0 ... 2.5, icon: "square.3.layers.3d.down.right", group: "Body") var damping = 0.9
@Param(0 ... 1, icon: "hammer", group: "Strike") var hardness = 0.85
struct Piece {
var name: String
var outline: Shape
var measured: StruckShape?
var center: Vector2
var pitch: Pitch
/// Where it was last struck, and when.
var hit: (at: Vector2, start: Double)?
var gains: [Double] = []
}
let synth = Synth(.chime, polyphony: 12)
var pieces: [Piece] = []
var lastStrike = -3.0
override func setup() {
synth.gain = 0.55
synth.reverb = Reverb(.hall, mix: 0.22)
let radius = Double(width) * 0.115
let outlines: [(String, Shape, Pitch)] = [
("circle", Self.polygon(sides: 96, radius: radius), "C4"),
("square", Self.polygon(sides: 4, radius: radius * 1.05, turn: .pi / 4), "E4"),
("triangle", Self.polygon(sides: 3, radius: radius * 1.2), "G4"),
("ring", Self.ring(outer: radius, inner: radius * 0.45), "A3"),
("oblong", Self.oblong(width: radius * 2.3, height: radius * 1.1), "D4"),
("blob", Self.blob(radius: radius), "F4"),
]
// Measured once, here, because it is real arithmetic. Striking one
// afterwards costs nothing.
for (index, entry) in outlines.enumerated() {
let column = index % 3
let row = index / 3
let center = Vector2(Double(width) * (0.24 + Double(column) * 0.26),
Double(height) * (0.28 + Double(row) * 0.35))
pieces.append(Piece(name: entry.0, outline: entry.1,
measured: StruckShape(entry.1, modes: 10),
center: center, pitch: entry.2))
}
}
override func draw() {
background(Color(hex: 0x111318))
// It plays itself until somebody plays it, so it never looks broken.
if time - lastStrike > 1.5, !pieces.isEmpty {
let index = Int(random(0, Double(pieces.count)))
let spread = Double(width) * 0.09
let offset = Vector2(random(-spread, spread), random(-spread, spread))
strike(index, at: pieces[index].center + offset)
}
for index in pieces.indices { drawPiece(index) }
drawCaption("Click a shape to strike it where you clicked.", edge: .top)
drawCaption("Nothing here picks a sound. Each shape rings at the frequencies "
+ "its own outline allows.")
}
override func mousePressed() {
let point = mouse
for index in pieces.indices {
let local = point - pieces[index].center
if pieces[index].outline.contains(local) { strike(index, at: point); return }
}
}
private func strike(_ index: Int, at point: Vector2) {
guard let measured = pieces[index].measured else { return }
let local = point - pieces[index].center
let body = measured.body(struckAt: local, decay: ring,
damping: damping, hardness: hardness)
synth.voice = Voice(struck: body, gain: 0.9)
synth.play(pieces[index].pitch, velocity: 0.9, for: ring)
pieces[index].hit = (at: local, start: time)
pieces[index].gains = body.modes.map(\.gain)
lastStrike = time
}
// MARK: Drawing
private func drawPiece(_ index: Int) {
let piece = pieces[index]
var glow = 0.0
if let hit = piece.hit {
glow = max(0, 1 - (time - hit.start) / max(0.2, ring))
if glow < 0.01 { pieces[index].hit = nil }
}
withState {
translate(piece.center)
// The outline swells a little where it was struck, so the picture
// says where the sound came from.
let swell = 1 + pow(glow, 0.5) * 0.035
scale(swell, swell)
noStroke()
fill(Color(white: 0.16))
drawShape(piece.outline)
noFill()
stroke(Colormap.magma.color(at: 0.35 + glow * 0.45)
.withAlpha(0.45 + glow * 0.55))
strokeWeight((1.4 + glow * 2.2) * scale)
drawShape(piece.outline)
if let hit = piece.hit, glow > 0.01 {
noStroke()
fill(Color(white: 1).withAlpha(glow))
drawCircle(center: hit.at, radius: (3 + glow * 5) * scale)
}
}
// What it rings at, drawn where those tones fall. The height of each is
// how much the last strike put into it, which is why the picture
// changes when you hit the same shape somewhere else.
let ratios = piece.measured?.ratios ?? []
let baseline = piece.center.y + Double(height) * 0.15
let span = Double(width) * 0.19
let widest = ratios.max() ?? 1
noStroke()
for (mode, ratio) in ratios.enumerated() {
let x = piece.center.x - span / 2 + ratio / widest * span
let gain = mode < piece.gains.count ? piece.gains[mode] : 0.25
let tall = (5 + gain * 26 * (0.25 + 0.75 * glow)) * scale
fill(Color(white: 0.75).withAlpha(0.25 + glow * 0.6))
drawRect(corner: Vector2(x - 1.2 * scale, baseline - tall),
width: 2.4 * scale, height: tall)
}
fill(Color(white: 0.45))
textFont(OutlineFont.system)
textSize(14 * scale)
textAlign(.center, .top)
drawText(piece.name, piece.center.x, baseline + 8 * scale)
}
// MARK: Shapes to strike
private static func polygon(sides: Int, radius: Double, turn: Double = 0) -> Shape {
Shape((0..<sides).map { step in
let angle = Double(step) / Double(sides) * .tau - .pi / 2 + turn
return Vector2(angle: angle) * radius
})
}
private static func ring(outer: Double, inner: Double) -> Shape {
Shape(outer: polygon(sides: 96, radius: outer).contours[0].points,
holes: [polygon(sides: 72, radius: inner).contours[0].points])
}
private static func oblong(width: Double, height: Double) -> Shape {
Shape([Vector2(-width / 2, -height / 2), Vector2(width / 2, -height / 2),
Vector2(width / 2, height / 2), Vector2(-width / 2, height / 2)])
}
/// A shape with no name, to make the point that any outline works.
private static func blob(radius: Double) -> Shape {
Shape((0..<120).map { step in
let angle = Double(step) / 120 * .tau
let wobble = 1 + 0.26 * sin(angle * 3 + 0.7) + 0.13 * sin(angle * 5 - 1.9)
return Vector2(angle: angle) * radius * wobble
})
}
}