Spectral
the two effects that work in the spectrum, and the stretch beside them: a phrase with its pitch moved and its length untouched (a harmonizer under a full mix), the instant the mouse goes down caught and held as a pad, and the bundled bar's recording slowed four times at the pitch it was recorded at, drawn over the original on one time axis (PitchShift, Freeze, stretched(by:))
- Run
swift run --package-path Examples Example-Audio-Spectral- Imports
OllinAudio- Source
- Examples/Audio/Spectral/Sketch.swift
import Ollin
import OllinAudio
/// The two effects that work in the spectrum, and the stretch beside them.
///
/// A phrase plays. In `shift` its pitch moves without its length, by the
/// semitones on the slider, and under a full mix the original sounds with
/// the moved copy, which is a harmonizer. In `freeze` the instant the mouse
/// goes down is caught and held for as long as the button is: whatever was
/// sounding becomes a pad, and letting go eases the phrase back in. In
/// `stretch` the bundled bar's recording is slowed by the factor and played
/// at the pitch it was recorded at, which a sampler cannot do by itself,
/// since a sampler moves pitch and length together like a tape.
///
/// The roll is the notes as they play, the moved copy over each one in the
/// warm color and exactly as long, and the bars under it are the spectrum of
/// what is coming out. In `freeze` the bars stop with the sound and the roll
/// shows how long the instant has been held.
@main
final class Spectral: Sketch {
enum Mode: String, ParamOption { case shift, freeze, stretch }
@Param(icon: "waveform.path", group: "The effect") var mode = Mode.shift
@Param(-24 ... 24, icon: "arrow.up.and.down", group: "Shift") var semitones = 7.0
@Param(0 ... 1, icon: "dial.medium", group: "Shift") var mix = 0.5
@Param(1 ... 8, icon: "clock", group: "Stretch") var factor = 4.0
let synth = Synth(.bell, polyphony: 8)
let pentatonic = Scale(.minorPentatonic, root: "A3")
let tempo: Tempo = 84
var counter = StepCounter(perBeat: 2)
var built = ""
var held = 0.0
var heldSince: Double?
/// The bundled bar, and the same bar slowed.
let bar = SampledInstrument.builtIn
var slowed: SampledInstrument?
var recorded: [Float] = []
var stretched: [Float] = []
/// What has played, for the roll: when, at what pitch, for how long, and
/// whether it is the moved copy of another.
struct Played { var at: Double; var pitch: Double; var length: Double; var moved: Bool }
var played: [Played] = []
let window = 6.0
let cool = Color(hex: 0x6FA8DC)
let warm = Color(hex: 0xE8A33D)
override func setup() {
synth.gain = 0.5
rebuild()
}
private var recipe: String { "\(mode)-\(semitones)-\(mix)-\(factor)" }
/// The chain for the mode. The freeze's amount is not part of the
/// recipe: it moves every frame with the mouse, and a moved setting
/// is set on the standing effect rather than rebuilding it.
private func rebuild() {
switch mode {
case .shift:
synth.instrument = nil
synth.voice = .bell
synth.effects = [.pitchShift(PitchShift(semitones: semitones, mix: mix)),
.reverb(Reverb(.hall, mix: 0.2))]
case .freeze:
synth.instrument = nil
synth.voice = .bell
synth.effects = [.freeze(Freeze(amount: held)), .reverb(Reverb(.hall, mix: 0.2))]
case .stretch:
// One recording, `factor` times as long, at the same pitch. The
// stretch is a thing done to the recording once, here, and then
// the sampler plays it like any other.
if let bar {
let index = bar.recordingIndex(for: 60) ?? 0
let recording = bar.recording(at: index, over: 0...127)
let longer = recording.stretched(by: factor)
slowed = SampledInstrument(name: "slowed", recordings: [longer])
recorded = recording.frames
stretched = longer.frames
}
synth.instrument = slowed
synth.voice = Voice(sampled: Sampler(), envelope: .plucked)
synth.effects = [.reverb(Reverb(.hall, mix: 0.2))]
}
played.removeAll()
built = recipe
}
override func draw() {
background(Color(hex: 0x0A0C11))
if built != recipe { rebuild() }
// The freeze follows the mouse, eased, so it fades in and out rather
// than switching; the amount is also the blend.
if mode == .freeze {
let target = mouseIsPressed ? 1.0 : 0.0
held += (target - held) * 0.2
if abs(held - target) < 0.002 { held = target }
synth.effects[0] = .freeze(Freeze(amount: held))
if held > 0, heldSince == nil { heldSince = time }
if held == 0 { heldSince = nil }
}
for step in counter.steps(upTo: tempo.beats(at: time)) {
switch mode {
case .shift, .freeze:
let degree = [0, 2, 4, 3, 5, 2, 1, 4][step % 8]
play(pentatonic[degree], velocity: step % 4 == 0 ? 0.9 : 0.6, for: 0.5)
if step % 8 == 0 { play(pentatonic[degree - 5], velocity: 0.7, for: 1.6) }
case .stretch:
// One note every two bars, since each lasts as long as the
// recording does, times the factor.
guard step % 8 == 0, let slowed else { continue }
let root = Double(slowed.recordingRoots[0])
let sounding = Double(stretched.count) / 48000
let pitch = Pitch(root + [0, 7, 3][(step / 8) % 3])
synth.play(pitch, velocity: 0.9, for: 8)
played.append(Played(at: time, pitch: pitch.midi, length: sounding / factor, moved: false))
played.append(Played(at: time, pitch: pitch.midi, length: sounding, moved: true))
}
}
played.removeAll { $0.at + $0.length < time - window }
drawRoll()
if mode == .stretch { drawRecordings() } else { drawSpectrum() }
switch mode {
case .shift:
let ratio = pow(2, semitones / 12)
drawCaption("Pitch moved \(semitones >= 0 ? "up" : "down") "
+ "\(abs(Int(semitones.rounded()))) semitones, a ratio of "
+ String(format: "%.3f", ratio) + ", with the length untouched.", edge: .top)
drawCaption(mix < 1 ? "The original sounds under the moved copy: a harmonizer."
: "Only the moved sound is heard; the roll shows both.")
case .freeze:
drawCaption(held > 0.01 ? "Holding the instant the mouse went down."
: "Press the mouse to hold whatever is sounding.", edge: .top)
drawCaption("The amount eases in and out, so the freeze fades rather than switches.")
case .stretch:
drawCaption("The bundled bar, \(String(format: "%.1f", factor)) times as long "
+ "at the pitch it was recorded at.", edge: .top)
drawCaption("Dim is the recording, bright is the same recording stretched.")
}
}
/// A note asked for, and written down for the roll: the note itself, and
/// in `shift` the copy the effect makes of it, as long and as many
/// semitones away as the effect says.
private func play(_ pitch: Pitch, velocity: Double, for length: Double) {
synth.play(pitch, velocity: velocity, for: length)
played.append(Played(at: time, pitch: pitch.midi, length: length, moved: false))
if mode == .shift {
played.append(Played(at: time, pitch: pitch.midi + semitones, length: length, moved: true))
}
}
/// The last few seconds of notes, scrolling left, with the moved copies
/// over them.
private func drawRoll() {
let frame = Rectangle(x: width * 0.1, y: height * 0.16, width: width * 0.8, height: height * 0.36)
let low = 36.0, high = 96.0
func x(_ t: Double) -> Double { frame.x + frame.width * (1 - (time - t) / window) }
func y(_ pitch: Double) -> Double { frame.bottomRight.y - frame.height * (pitch - low) / (high - low) }
let rowHeight = frame.height / (high - low)
noStroke()
for octave in stride(from: low, through: high, by: 12) {
fill(Color(white: 1, alpha: 0.05))
drawRect(frame.x, y(octave) - 0.5, frame.width, 1)
}
if mode == .freeze, let heldSince {
fill(warm.withAlpha(0.12 + 0.18 * held))
drawRect(x(heldSince), frame.y, x(time) - x(heldSince), frame.height)
}
for note in played {
let start = max(frame.x, x(note.at))
let end = min(frame.bottomRight.x, x(note.at + note.length))
guard end > start else { continue }
if note.moved {
fill(warm.withAlpha(mode == .shift ? 0.35 + 0.65 * mix : 0.9))
} else {
fill(cool.withAlpha(mode == .shift && mix >= 1 ? 0.35 : 0.85))
}
drawRect(start, y(note.pitch) - rowHeight * 0.45, end - start, rowHeight * 0.9)
}
noFill()
stroke(Color(white: 1, alpha: 0.12))
strokeWeight(1 * scale)
drawRect(frame.x, frame.y, frame.width, frame.height)
}
/// What is coming out, as bars across the spectrum.
private func drawSpectrum() {
let levels = synth.bands(56)
let frame = Rectangle(x: width * 0.1, y: height * 0.58, width: width * 0.8, height: height * 0.24)
let gap = 3 * scale
let barWidth = (frame.width - gap * Double(levels.count - 1)) / Double(levels.count)
noStroke()
for (index, level) in levels.enumerated() {
let lit = min(Double(level), 1)
let barHeight = max(2 * scale, frame.height * lit)
fill((mode == .freeze && held > 0.01 ? warm : cool).withAlpha(0.35 + 0.65 * lit))
drawRect(frame.x + Double(index) * (barWidth + gap), frame.bottomRight.y - barHeight,
barWidth, barHeight)
}
}
/// The recording and its stretch on one time axis, so the stretch reads
/// as longer rather than merely different.
private func drawRecordings() {
guard !recorded.isEmpty, !stretched.isEmpty else { return }
let frame = Rectangle(x: width * 0.1, y: height * 0.58, width: width * 0.8, height: height * 0.24)
let span = Double(stretched.count)
func outline(_ samples: [Float], color: Color) {
let columns = 400
var top: [Vector2] = [], bottom: [Vector2] = []
let visible = Int(Double(columns) * Double(samples.count) / span)
guard visible > 1 else { return }
for column in 0..<visible {
let from = column * samples.count / visible
let to = max(from + 1, (column + 1) * samples.count / visible)
var peak: Float = 0
for index in from..<to { peak = max(peak, abs(samples[index])) }
let x = frame.x + frame.width * Double(column) / Double(columns)
let y = Double(peak) * frame.height * 0.48
top.append(Vector2(x, frame.center.y - y))
bottom.append(Vector2(x, frame.center.y + y))
}
noStroke()
fill(color)
drawShape(Shape(top + bottom.reversed()))
}
outline(stretched, color: warm.withAlpha(0.8))
outline(recorded, color: cool.withAlpha(0.45))
}
}