A SpriteKit-based fretboard library for iOS, iPadOS, macOS, tvOS, and visionOS.
Built on MusicTheory v2.0.
- Swift 5.9+
- iOS 13+ / macOS 10.15+ / tvOS 13+ / visionOS 1+
// Package.swift
.package(url: "https://github.com/cemolcay/Fretboard.git", from: "2.0.0")import SpriteKit
import Fretboard
// 1. Create a model (pure geometry — no selection state)
let fretboard = Fretboard(tuning: GuitarTuning.standard, startIndex: 0, count: 12)
// 2. Create the scene
let scene = FretboardScene(fretboard: fretboard)
scene.noteDelegate = self // optional — for synth/sampler integration
// 3. Present in an SKView (UIKit / AppKit)
skView.presentScene(scene)
// — or — in SwiftUI:
// SpriteView(scene: scene)
// 4. Show notes (scale, chord, or any set of pitches)
scene.showScale(Scale(type: .major, root: .c))
// 5. Highlight individual notes (e.g. live MIDI)
scene.highlightPitch(Pitch(noteName: .e, octave: 4)) // creates a dot if not shown
scene.unhighlightPitch(Pitch(noteName: .e, octave: 4))The library separates geometry from rendering state:
Fretboard(model) — pure data: tuning, fret range, direction. Knows nothing about selection or colors.FretboardScene(scene) — owns all visual state: which dots are shown, which are highlighted, capo-mode rendering.
This lets you combine multiple independent marking layers at once — for example, showing a scale while also reflecting live incoming MIDI notes on top of it.
Codable final class — the consuming app owns it, reads it, writes it.
| Property | Type | Description |
|---|---|---|
tuning |
Tuning |
Open-string pitches. Default: standard guitar. |
startIndex |
Int |
First fret shown. 0 = open strings. |
count |
Int |
Number of frets shown. |
direction |
FretboardDirection |
.horizontal or .vertical. |
isStringsFlipped |
Bool |
Reverses string order for the current direction. Default false. |
isFretsFlipped |
Bool |
Reverses the fret/neck axis. Default false. |
notes |
[FretboardNote] |
Read-only computed pitch grid. |
orderedStrings |
[Pitch] |
Strings in display order (direction- and flip-aware). |
octaves |
[Int] |
Unique octaves currently visible, sorted. |
Mutating any property (tuning, startIndex, count, direction, isStringsFlipped) rebuilds the note grid and, if the model is wired to a scene, triggers scene.reload() automatically via the fretboard didSet. isFretsFlipped is a visual-only transform — changing it requires an explicit scene.reload() call.
Pitch lookup:
// Returns every grid position whose MIDI note number matches `pitch`.
let positions = fretboard.notes(matching: Pitch(noteName: .a, octave: 2))Flipping the fretboard:
isStringsFlipped reverses the string order (cross axis):
fretboard.isStringsFlipped = true // triggers rebuildNotes + scene.reload() automatically
// Horizontal (default false): high E at top → low E at top when flipped.
// Vertical (default false): high E at left → low E at left when flipped.isFretsFlipped reverses the fret/neck axis (visual-only — the pitch grid is unchanged):
fretboard.isFretsFlipped = true
scene.reload() // explicit reload required — isFretsFlipped doesn't rebuild notesThe convention for not-flipped is: nut at the leading edge.
| Direction | isFretsFlipped |
Nut position |
|---|---|---|
.horizontal |
false (default) |
Left — fret numbers increase left → right |
.horizontal |
true |
Right — useful for left-handed players |
.vertical |
false (default) |
Top — fret 1 visible at the top |
.vertical |
true |
Bottom — higher frets at the top |
Persistence:
let data = try JSONEncoder().encode(fretboard) // persists tuning/startIndex/count/direction/flip flags
let loaded = try JSONDecoder().decode(Fretboard.self, from: data)
scene.fretboard = loaded // triggers scene.reload() automaticallyCodable struct { var name: String; var strings: [Pitch] } — uniform for built-ins and custom:
// Built-in
fretboard.tuning = GuitarTuning.standard // E2 A2 D3 G3 B3 E4
fretboard.tuning = BassTuning.standard4String // E1 A1 D2 G2
fretboard.tuning = UkuleleTuning.standard // G4 C4 E4 A4
// All built-in guitar tunings
let allGuitar: [Tuning] = GuitarTuning.all
// Custom (persists exactly like built-ins)
let custom = Tuning(name: "Open G Slide", strings: [
Pitch(noteName: .d, octave: 2),
Pitch(noteName: .g, octave: 2),
Pitch(noteName: .d, octave: 3),
Pitch(noteName: .g, octave: 3),
Pitch(noteName: .b, octave: 3),
Pitch(noteName: .d, octave: 4),
])
fretboard.tuning = customlet scene = FretboardScene(
fretboard: fretboard,
configuration: FretboardConfiguration() // optional
)The marking API is split by targeting:
Shows a dot at every board position whose MIDI number matches the pitch. Use this for scales, chords, and any display where all octave positions should light up.
scene.showPitch(Pitch(noteName: .c, octave: 4)) // all C4s on the neck
scene.showPitches([c4, e4, g4])
scene.hidePitch(Pitch(noteName: .c, octave: 4))
scene.clearNotes() // removes shown set; live highlights survive as transient
scene.removeAllNotes() // unconditional wipeTheory conveniences (expand across all visible octaves automatically):
scene.showScale(Scale(type: .major, root: .c))
scene.showChord(Chord(type: .dominant7, root: .g))Shows a dot at one specific string+fret. Use this for CAGED boxes, chord voicings, and any display where the physical fingering matters — not just which pitch sounds.
FretboardNote values come from fretboard.notes or fretboard.notes(matching:).
scene.showNote(specificFretboardNote)
scene.showNotes(boxNotes) // a set of specific positions
scene.hideNote(specificFretboardNote)Chord shape example:
// Show only the notes of one specific G major open chord voicing:
let voicingIDs = ["5-2", "4-2", "3-0", "2-0", "1-0", "0-3"] // "stringIndex-fretIndex"
let voicingNotes = fretboard.notes.filter { voicingIDs.contains($0.id) }
scene.showNotes(voicingNotes)Highlighting is independent of the shown set — used for user presses and live MIDI equally.
// Pitch-based (highlights every matching position)
scene.highlightPitch(Pitch(noteName: .e, octave: 4)) // creates transient dot if not shown
scene.unhighlightPitch(Pitch(noteName: .e, octave: 4)) // removes transient dots; dims shown ones
// Position-based (highlights exactly one string+fret)
scene.highlightNote(specificFretboardNote)
scene.unhighlightNote(specificFretboardNote)A highlighted dot reverts to normal appearance on unhighlight… but is not removed if it was part of the shown set.
Scale + live MIDI at the same time:
scene.showScale(Scale(type: .major, root: .c))
func midiNoteOn(pitch: Pitch) {
scene.highlightPitch(pitch) // off-scale notes get a transient dot
}
func midiNoteOff(pitch: Pitch) {
scene.unhighlightPitch(pitch)
}Every show/highlight call accepts an optional FretboardNoteStyle. The style overrides
specific visual properties for those dots; nil fields inherit from the configuration
default, which in turn inherits from the library's built-in defaults.
Resolution order:
per-note override → config.noteStyle / highlightNoteStyle → FretboardNoteStyle.defaultNote / .defaultHighlight
public struct FretboardNoteStyle: Codable, Hashable {
public var color: FretboardColor? // dot fill
public var textColor: FretboardColor?
public var borderColor: FretboardColor?
public var borderWidth: CGFloat?
public var label: String? // nil = note name, "" = hide, "♭3" = custom text
}Label semantics:
nil→ use the note name (e.g. "C", "F#")""(empty) → hide the label for this dot, even ifisDrawNoteNameis on- any other string → render that string (degree numbers, interval names, finger numbers…)
Degree coloring example:
let degreeColors: [Int: FretboardColor] = [
1: FretboardColor(red: 1, green: 0.3, blue: 0.3, alpha: 1), // root = red
3: FretboardColor(red: 0.3, green: 0.8, blue: 0.3, alpha: 1), // third = green
5: FretboardColor(red: 0.3, green: 0.5, blue: 1.0, alpha: 1), // fifth = blue
]
for noteName in scale.noteNames {
for octave in fretboard.octaves {
let pitch = Pitch(noteName: noteName, octave: octave)
if let degree = scale.degree(of: noteName) {
scene.showPitch(pitch, style: FretboardNoteStyle(
color: degreeColors[degree],
label: "\(degree)"
))
}
}
}
// Scale changed → scene.removeAllNotes(); re-run loop.AUv3 MIDI in-vs-out-of-scale coloring:
let inScale = FretboardNoteStyle(color: FretboardColor(red: 0.2, green: 0.85, blue: 0.4, alpha: 1))
let outScale = FretboardNoteStyle(color: FretboardColor(red: 0.9, green: 0.2, blue: 0.2, alpha: 1))
func midiNoteOn(pitch: Pitch) {
let isInScale = currentScale.noteNames.contains(pitch.noteName)
scene.highlightPitch(pitch, style: isInScale ? inScale : outScale)
}The app decides which frets get inlays and which style; the library renders them.
Inlays survive scene.reload() — call these once after setup.
public enum FretInlay { case single, double }
// Standard guitar layout:
[3, 5, 7, 9, 15, 17, 19, 21].forEach { scene.showFretInlay(at: $0) }
[12, 24].forEach { scene.showFretInlay(at: $0, .double) }
// Manage individually:
scene.hideFretInlay(at: 12)
scene.clearFretInlays()The dot color comes from configuration.fretMarkerColor (default: .lightGray).
| Property | Default | Description |
|---|---|---|
stringColor |
.black |
String line color. |
fretColor |
.darkGray |
Fret line color. |
stringLabelColor |
.black |
String-name label color. |
fretLabelColor |
.black |
Fret-number label color. |
backgroundColor |
.clear |
Scene background. |
stringWidth |
0.5 |
String line width (pt). |
fretWidth |
2 |
Fret line width (pt). |
nutWidthMultiplier |
2 |
Nut drawn at fretWidth × nutWidthMultiplier. |
| Property | Default | Description |
|---|---|---|
noteStyle |
.defaultNote |
Style for all dots in their normal state. All fields optional — nil falls back to built-in. |
highlightNoteStyle |
.defaultHighlight |
Style for all dots when highlighted. |
fretMarkerColor |
.lightGray |
Fill color for fret inlay markers. |
Built-in defaults:
defaultNote |
defaultHighlight |
|
|---|---|---|
color |
.black |
orange |
textColor |
.white |
.white |
borderColor |
.clear |
.clear |
borderWidth |
0 |
0 |
Setting properties on noteStyle or highlightNoteStyle overrides just those fields:
var cfg = FretboardConfiguration()
// Custom normal dot: dark blue fill, white text, subtle border
cfg.noteStyle = FretboardNoteStyle(
color: FretboardColor(red: 0.15, green: 0.3, blue: 0.8, alpha: 1),
textColor: .white,
borderColor: FretboardColor(white: 0, alpha: 0.2),
borderWidth: 1.5
)
// Custom highlight: vivid orange (textColor/border inherit from default)
cfg.highlightNoteStyle = FretboardNoteStyle(
color: FretboardColor(red: 0.95, green: 0.35, blue: 0.1, alpha: 1)
)Individual show/highlight calls can further override specific dots:
scene.showPitch(rootPitch, style: FretboardNoteStyle(color: .init(red: 1, green: 0.3, blue: 0.3, alpha: 1), label: "1"))
// All other pitches use config.noteStyle defaults.| Property | Default | Description |
|---|---|---|
fretSizing |
.fill |
How large each fret is along the neck axis. |
alignment |
.center |
Where to anchor the board when smaller than the scene. |
noteOffset |
5 |
Inset shrinking the dot away from cell edges (pt). |
isDrawNoteName |
true |
Show note-name labels on dots globally. Per-note label overrides this. |
isDrawStringName |
true |
Show string-name labels at the nut. |
isDrawFretNumber |
true |
Show fret-number labels. |
| Property | Default | Description |
|---|---|---|
isCapoModeOn |
false |
When true, adjacent shown dots on the same fret merge into a capsule bar. Purely visual. |
scene.configuration.isCapoModeOn = true.fill // All frets fill the scene's neck axis exactly. No scroll.
.fit // Proportional fit: fret length = string spacing × fitAspectRatio (1.5),
// capped at the fill length so the board never scrolls. The board
// centers in both axes when smaller than the scene. Ideal for large
// screens (iPad) where .fill makes frets disproportionately tall.
.fixed(CGFloat) // Fixed points per fret. Scrollable when total length overflows the scene.
.multiplier(CGFloat) // Fraction of the scene's neck axis per fret.fitAspectRatio is a public static constant on FretSizing; adjust it if you want frets wider or narrower relative to string spacing.
When content overflows, the scene enters scroll mode automatically (drag to pan — no host scroll view needed). When it fits, glide mode activates. See Scroll vs. glide mode in the Playback section.
.center // Centered in the neck axis (default).
.leading // Flush to the nut end.
.trailing // Flush to the far-fret end.Implement FretboardSceneDelegate and assign it to scene.noteDelegate.
The delegate fires on user interaction only — programmatic showPitch / highlightPitch calls do not fire it.
class MyViewController: UIViewController, FretboardSceneDelegate {
func fretboardScene(_ scene: FretboardScene, noteOn note: FretboardNote) {
midiOut.send(noteOn: note.pitch.midiNoteNumber, velocity: 100, channel: 0)
}
func fretboardScene(_ scene: FretboardScene, noteOff note: FretboardNote) {
midiOut.send(noteOff: note.pitch.midiNoteNumber, channel: 0)
}
}- noteOn fires immediately on touch-down for minimum latency.
- noteOff fires on touch-up or cancel.
- Polyphonic — each finger is tracked independently, so chords and multi-touch glide both work.
- The touched fret is automatically highlighted on press and unhighlighted on release.
Scroll vs. glide mode — determined automatically by whether the neck content fits in the scene:
| Mode | When | Drag behaviour |
|---|---|---|
| Glide | Neck fits the scene (no overflow) | Dragging slides across notes — noteOff fires as the finger leaves a fret, noteOn fires as it enters the next. |
| Scroll | Neck overflows the scene | Dragging pans the neck after a short threshold; noteOff fires on conversion so notes never get stuck. |
Override at any time via isScrollingEnabled:
// Lock a long neck into glide mode (e.g. via a lock button)
scene.isScrollingEnabled = false
// Restore automatic behaviour
scene.isScrollingEnabled = true // or call scene.reload() to re-detect from geometryThis library powers FretBud, a chord & scale reference app for guitar, bass, and more.