Zum Inhalt

native/macos/KuroNativeSaver/Core/Synth.swift

// Synth — the atmospheric audio: a 60 Hz CRT hum + an 80s "Carpenter" soundscape
// (triangle drone pad, sawtooth bass pulse, square arpeggio) with LFO-swept
// one-pole low-passes. Ported from audio/synth.ts. Rendered in a single
// real-time AVAudioSourceNode callback (allocation-free). Off by default —
// screensavers usually run muted; toggle in the app config.
//
// NOTE: audio output cannot be verified in this headless build — needs on-device ears.

import AVFoundation

final class Synth {
    private let engine = AVAudioEngine()
    private var source: AVAudioSourceNode?
    private var sr: Double = 48000
    var volume: Float = 0.4
    private(set) var running = false

    // oscillator phases (cycles)
    private var pHum = 0.0, pDroneA = 0.0, pDroneB = 0.0, pBass = 0.0, pArp = 0.0
    private var idx: Double = 0            // global sample index → seconds
    private var lpDrone = 0.0, lpBass = 0.0 // one-pole filter states
    private let synthMaster: Float = 0.4
    private let arpNotes: [Double] = [440, 329.63, 440, 587.33]

    func start() {
        guard !running else { return }
        let fmt = engine.outputNode.outputFormat(forBus: 0)
        sr = fmt.sampleRate > 0 ? fmt.sampleRate : 48000
        let node = AVAudioSourceNode { [weak self] _, _, frameCount, ablPtr -> OSStatus in
            guard let self else { return noErr }
            let abl = UnsafeMutableAudioBufferListPointer(ablPtr)
            let n = Int(frameCount)
            for i in 0..<n {
                let s = self.nextSample()
                for buf in abl {
                    let p = buf.mData!.assumingMemoryBound(to: Float.self)
                    p[i] = s
                }
            }
            return noErr
        }
        engine.attach(node)
        engine.connect(node, to: engine.mainMixerNode, format: fmt)
        source = node
        do { try engine.start(); running = true } catch { running = false }
    }

    func stop() {
        guard running else { return }
        engine.stop()
        if let s = source { engine.detach(s) }
        source = nil; running = false
    }

    // MARK: - DSP (audio thread)

    private func lpCoeff(_ fc: Double) -> Double { 1 - exp(-2 * .pi * fc / sr) }

    private func nextSample() -> Float {
        let t = idx / sr
        idx += 1
        let inc = 1.0 / sr

        // CRT hum 60 Hz
        pHum += 60 * inc; let hum = sin(2 * .pi * pHum) * 0.025

        // drone: triangle 110 + 164.81, lowpass LFO 400..2000 Hz (1/30 Hz)
        pDroneA += 110 * inc; pDroneB += 164.81 * inc
        func tri(_ p: Double) -> Double { 2 * abs(2 * (p - floor(p + 0.5))) - 1 }
        let droneRaw = (tri(pDroneA) + tri(pDroneB)) * 0.5
        let droneFc = 1200 + sin(2 * .pi * t / 30) * 800
        lpDrone += lpCoeff(droneFc) * (droneRaw - lpDrone)
        let drone = lpDrone * 0.16

        // bass: saw 110, pulsed every 0.75s (AD env), lowpass LFO 300..900 (0.25Hz)
        pBass += 110 * inc
        let saw = 2 * (pBass - floor(pBass + 0.5))
        let bp = t.truncatingRemainder(dividingBy: 0.75)
        let bassEnv = bp < 0.01 ? bp / 0.01 : exp(-(bp - 0.01) * 6)
        let bassFc = 600 + sin(2 * .pi * t * 0.25) * 300
        lpBass += lpCoeff(bassFc) * (saw - lpBass)
        let bass = lpBass * bassEnv * 0.22

        // arpeggio: square, 16ths @ ~80 BPM (0.1875s/step), AD env
        let step = Int(t / 0.1875) % arpNotes.count
        pArp += arpNotes[step] * inc
        let sq = sin(2 * .pi * pArp) >= 0 ? 1.0 : -1.0
        let ap = t.truncatingRemainder(dividingBy: 0.1875)
        let arpEnv = ap < 0.005 ? ap / 0.005 : exp(-(ap - 0.005) * 22)
        let arp = sq * arpEnv * 0.09

        let mix = (drone + bass + arp) * Double(synthMaster) + hum
        return Float(mix) * volume
    }
}