Files
luma/Sources/LumaCore/Audio/Synth.swift
Ole André Vadla Ravnås f8d25015ae Drop the comments that only restate the code
A comment is a last resort, and these were not one: section banners,
doc headers naming what the declaration below already names, and
notes saying in prose what the next line says in code.

What is left is what the code cannot show -- an ordering that matters,
a platform quirk, why the obvious way fails.
2026-08-13 13:09:18 +02:00

244 lines
8.1 KiB
Swift

import CLumaAudio
import Foundation
/// The host-side synthesiser both frontends share. Voices are mixed on the
/// audio device's own thread by `SynthEngine`, reached only through a
/// lock-free command queue, so nothing here can stall it.
@MainActor
public final class Synth {
public private(set) var isRunning = false
/// Master level, 0..1.
public var level: Float {
didSet { SynthEngine.level = level }
}
public init(level: Float = 0.6) {
self.level = level
SynthEngine.prepare()
}
deinit {
luma_audio_stop()
}
/// Answers false when the host has no usable output device, which is not
/// fatal: playing stays callable and simply goes unheard.
@discardableResult
public func start() -> Bool {
guard !isRunning else { return true }
isRunning = luma_audio_start()
if isRunning {
SynthEngine.level = level
}
return isRunning
}
public func stop() {
guard isRunning else { return }
luma_audio_stop()
isRunning = false
}
/// The recipe every voice subsequently played on this channel is built
/// from. Each channel carries its own, so a bass and a lead can sound
/// together.
public func use(_ patch: SynthPatch, channel: Int = 0) {
SynthEngine.setPatch(patch.values, channel: channel)
}
/// A patch that sustains needs the answered voice released; one that
/// decays to silence does not.
@discardableResult
public func play(frequency: Float, velocity: Float = 1, channel: Int = 0) -> Int32 {
SynthEngine.play(frequency: frequency, velocity: velocity, channel: channel)
}
public func release(voice: Int32) {
SynthEngine.release(voice: voice)
}
/// Stops every pattern and takes every voice with it, leaving what each
/// channel is set to alone.
public func hush() {
SynthEngine.hush()
}
/// Back to how it started: every channel's patch, colour and place, the
/// echo, and the level.
public func reset() {
SynthEngine.reset(channel: nil)
level = 0.6
}
public func reset(channel: Int) {
SynthEngine.reset(channel: channel)
}
/// Where a channel sits between the speakers: -1 hard left, 1 hard right.
public func pan(_ pan: Float, channel: Int = 0) {
SynthEngine.setPan(pan, channel: channel)
}
/// One tap of delay across everything, crossing sides as it comes round:
/// how far back it reaches, how much comes round again, and how much of
/// it is heard.
public func echo(seconds: Float, feedback: Float, mix: Float) {
SynthEngine.setEcho(seconds: seconds, feedback: feedback, mix: mix)
}
/// Mixes without a device, for previews and for checking a patch makes
/// the sound it claims to.
public func renderOffline(frameCount: Int, channels: Int = 1) -> [Float] {
var frames = [Float](repeating: 0, count: frameCount * channels)
frames.withUnsafeMutableBufferPointer { buffer in
SynthEngine.mix(into: buffer.baseAddress!, frameCount: frameCount, channels: channels)
}
return frames
}
}
public enum Waveform: Int32, Sendable, CaseIterable {
case sine = 0
case triangle = 1
case saw = 2
case square = 3
case noise = 4
}
public struct SynthPatch: Sendable, Equatable {
public var waveform: Waveform
public var attack: Float
public var decay: Float
public var sustain: Float
public var release: Float
public var cutoff: Float
public var resonance: Float
public var detuneSemitones: Float
public var gain: Float
/// Where a pulse sits between its edges; a half is a square.
public var pulseWidth: Float = 0.5
public var lfoRate: Float = 0
public var lfoToPitch: Float = 0
public var lfoToWidth: Float = 0
public var lfoToCutoff: Float = 0
/// Octaves the filter opens by when a note is struck, and how long it
/// takes to shut again.
public var cutoffEnvelope: Float = 0
public var cutoffDecay: Float = 0.2
public var syncsDetuned: Bool = false
public var ringMix: Float = 0
public var drive: Float = 0
public init(
waveform: Waveform,
attack: Float,
decay: Float,
sustain: Float,
release: Float,
cutoff: Float,
resonance: Float,
detuneSemitones: Float = 0,
gain: Float = 1,
pulseWidth: Float = 0.5,
lfoRate: Float = 0,
lfoToPitch: Float = 0,
lfoToWidth: Float = 0,
lfoToCutoff: Float = 0,
cutoffEnvelope: Float = 0,
cutoffDecay: Float = 0.2,
syncsDetuned: Bool = false,
ringMix: Float = 0,
drive: Float = 0
) {
self.pulseWidth = pulseWidth
self.lfoRate = lfoRate
self.lfoToPitch = lfoToPitch
self.lfoToWidth = lfoToWidth
self.lfoToCutoff = lfoToCutoff
self.cutoffEnvelope = cutoffEnvelope
self.cutoffDecay = cutoffDecay
self.syncsDetuned = syncsDetuned
self.ringMix = ringMix
self.drive = drive
self.waveform = waveform
self.attack = attack
self.decay = decay
self.sustain = sustain
self.release = release
self.cutoff = cutoff
self.resonance = resonance
self.detuneSemitones = detuneSemitones
self.gain = gain
}
/// Reedy and moving, the way a chip lead is: the pulse breathes rather
/// than sitting still.
public static let pwmLead = SynthPatch(
waveform: .square, attack: 0.004, decay: 0.5, sustain: 0.7, release: 0.12,
cutoff: 4200, resonance: 0.2, gain: 0.4,
pulseWidth: 0.35, lfoRate: 4.5, lfoToWidth: 0.3)
/// Filter struck open and shut on every note, which is the whole of an
/// acid line.
public static let acid = SynthPatch(
waveform: .saw, attack: 0.002, decay: 0.28, sustain: 0, release: 0.04,
cutoff: 320, resonance: 0.92, gain: 0.5,
cutoffEnvelope: 3.6, cutoffDecay: 0.22, drive: 0.35)
public static let syncLead = SynthPatch(
waveform: .saw, attack: 0.003, decay: 0.4, sustain: 0.5, release: 0.08,
cutoff: 5200, resonance: 0.25, detuneSemitones: 7.02, gain: 0.35,
syncsDetuned: true, drive: 0.2)
public static let bell = SynthPatch(
waveform: .sine, attack: 0.001, decay: 0.9, sustain: 0, release: 0.3,
cutoff: 0, resonance: 0, detuneSemitones: 15.4, gain: 0.4,
ringMix: 0.9)
public static let pad = SynthPatch(
waveform: .saw, attack: 0.35, decay: 0.6, sustain: 0.65, release: 0.6,
cutoff: 900, resonance: 0.3, detuneSemitones: 0.11, gain: 0.3,
lfoRate: 0.25, lfoToPitch: 0.08, lfoToCutoff: 1.2)
public static let blip = SynthPatch(
waveform: .triangle, attack: 0.004, decay: 0.18, sustain: 0, release: 0.05,
cutoff: 2600, resonance: 0.35, detuneSemitones: 0.08, gain: 0.5)
public static let pulse = SynthPatch(
waveform: .square, attack: 0.001, decay: 0.09, sustain: 0, release: 0.01,
cutoff: 0, resonance: 0, gain: 0.5)
public static let bass = SynthPatch(
waveform: .saw, attack: 0.002, decay: 0.22, sustain: 0, release: 0.03,
cutoff: 900, resonance: 0.5, detuneSemitones: 0.06, gain: 0.7)
public static let noiseHit = SynthPatch(
waveform: .noise, attack: 0.001, decay: 0.07, sustain: 0, release: 0.01,
cutoff: 4200, resonance: 0.2, gain: 0.4)
var values: SynthEngine.PatchValues {
var raw = SynthEngine.PatchValues()
raw.waveform = waveform.rawValue
raw.detuneSemitones = detuneSemitones
raw.attack = attack
raw.decay = decay
raw.sustain = sustain
raw.release = release
raw.cutoff = cutoff
raw.resonance = resonance
raw.gain = gain
raw.pulseWidth = pulseWidth
raw.lfoRate = lfoRate
raw.lfoToPitch = lfoToPitch
raw.lfoToWidth = lfoToWidth
raw.lfoToCutoff = lfoToCutoff
raw.cutoffEnvelope = cutoffEnvelope
raw.cutoffDecay = cutoffDecay
raw.syncsDetuned = syncsDetuned
raw.ringMix = ringMix
raw.drive = drive
return raw
}
}