diff --git a/packages/preview/staves/0.1.0/LICENSE.txt b/packages/preview/staves/0.1.0/LICENSE.txt
new file mode 100644
index 0000000000..de2617a92e
--- /dev/null
+++ b/packages/preview/staves/0.1.0/LICENSE.txt
@@ -0,0 +1,11 @@
+= MIT License
+
+Copyright 2025 Matthew Davis
+
+https://opensource.org/license/mit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/preview/staves/0.1.0/README.md b/packages/preview/staves/0.1.0/README.md
new file mode 100644
index 0000000000..812f2bdf47
--- /dev/null
+++ b/packages/preview/staves/0.1.0/README.md
@@ -0,0 +1,7 @@
+# Staves (Typst Package)
+
+This Typst package is used to draw musical scales.
+
+See `docs.typ` for usage information.
+
+
\ No newline at end of file
diff --git a/packages/preview/staves/0.1.0/assets/README.md b/packages/preview/staves/0.1.0/assets/README.md
new file mode 100644
index 0000000000..46326b8428
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/README.md
@@ -0,0 +1,24 @@
+# Symbol SVGs
+
+This folder contains SVGs of clefs, sharps/flats, and note heads.
+Sharps and flats are available as unicode characters, but they render a bit strangely when blown up. By using SVGs, this means the output of this library will be robust to changes in font size, typeface etc.
+
+These files came from Wikipedia. They are all in the public domain. The license for this package does not apply to these files. For some I edited them to delete the stave lines.
+
+Alto: https://commons.wikimedia.org/wiki/File:Alto_clef.svg
+
+Bass: https://commons.wikimedia.org/wiki/File:Bass_clef.svg
+
+Treble: https://en.wikipedia.org/wiki/File:GClef.svg
+
+Flat: https://commons.wikimedia.org/wiki/File:Flat.svg
+
+Sharp: https://commons.wikimedia.org/wiki/File:Sharp.svg
+
+Double Sharp (X): https://commons.wikimedia.org/wiki/File:Llpd%2B2.svg
+
+Natural: https://commons.wikimedia.org/wiki/File:%D0%97%D0%BE%D1%80%D1%8F._%D0%9D%D0%BE%D1%82%D0%B8._1894._08._185._4.svg
+
+Quarter note: https://commons.wikimedia.org/wiki/File:Treblea4.svg
+
+Whole note: https://commons.wikimedia.org/wiki/File:Whole_note.svg
\ No newline at end of file
diff --git a/packages/preview/staves/0.1.0/assets/accidental/double-sharp-x.svg b/packages/preview/staves/0.1.0/assets/accidental/double-sharp-x.svg
new file mode 100644
index 0000000000..76210b5a05
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/accidental/double-sharp-x.svg
@@ -0,0 +1,55 @@
+
+
+
+
diff --git a/packages/preview/staves/0.1.0/assets/accidental/flat.svg b/packages/preview/staves/0.1.0/assets/accidental/flat.svg
new file mode 100644
index 0000000000..54ee9a0e31
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/accidental/flat.svg
@@ -0,0 +1,24 @@
+
+
+
diff --git a/packages/preview/staves/0.1.0/assets/accidental/natural.svg b/packages/preview/staves/0.1.0/assets/accidental/natural.svg
new file mode 100644
index 0000000000..6ca70184bf
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/accidental/natural.svg
@@ -0,0 +1,67 @@
+
+
diff --git a/packages/preview/staves/0.1.0/assets/accidental/sharp.svg b/packages/preview/staves/0.1.0/assets/accidental/sharp.svg
new file mode 100644
index 0000000000..b470e87cdd
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/accidental/sharp.svg
@@ -0,0 +1,26 @@
+
+
+
diff --git a/packages/preview/staves/0.1.0/assets/clefs/alto.svg b/packages/preview/staves/0.1.0/assets/clefs/alto.svg
new file mode 100644
index 0000000000..b29255bbf1
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/clefs/alto.svg
@@ -0,0 +1,33 @@
+
+
+
diff --git a/packages/preview/staves/0.1.0/assets/clefs/bass.svg b/packages/preview/staves/0.1.0/assets/clefs/bass.svg
new file mode 100644
index 0000000000..ad1d1f4f6d
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/clefs/bass.svg
@@ -0,0 +1,63 @@
+
+
+
+
diff --git a/packages/preview/staves/0.1.0/assets/clefs/treble.svg b/packages/preview/staves/0.1.0/assets/clefs/treble.svg
new file mode 100644
index 0000000000..e396ee0e50
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/clefs/treble.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/preview/staves/0.1.0/assets/notes/crotchet-head.svg b/packages/preview/staves/0.1.0/assets/notes/crotchet-head.svg
new file mode 100644
index 0000000000..32394b1c10
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/notes/crotchet-head.svg
@@ -0,0 +1,75 @@
+
+
diff --git a/packages/preview/staves/0.1.0/assets/notes/crotchet-up.svg b/packages/preview/staves/0.1.0/assets/notes/crotchet-up.svg
new file mode 100644
index 0000000000..396258d0ca
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/notes/crotchet-up.svg
@@ -0,0 +1,83 @@
+
+
diff --git a/packages/preview/staves/0.1.0/assets/notes/whole.svg b/packages/preview/staves/0.1.0/assets/notes/whole.svg
new file mode 100644
index 0000000000..9af4d8c1f2
--- /dev/null
+++ b/packages/preview/staves/0.1.0/assets/notes/whole.svg
@@ -0,0 +1,104 @@
+
+
diff --git a/packages/preview/staves/0.1.0/docs/docs.typ b/packages/preview/staves/0.1.0/docs/docs.typ
new file mode 100644
index 0000000000..e88d4281c7
--- /dev/null
+++ b/packages/preview/staves/0.1.0/docs/docs.typ
@@ -0,0 +1,412 @@
+#import "/src/lib.typ": stave, major-scale, minor-scale, arpeggio, chromatic-scale, all-clefs, all-note-durations, _allowed-sides, _minor-types, _symbol-data
+
+= Staves Typst Package
+
+Author: Matthew Davis
+
+This Typst package is used to draw musical scales.
+
+For now this is restricted to only one stave (set of lines).
+This package can be used to write arbitrary notes, but is not intended to be used for entire songs.
+
+
+#figure(
+ major-scale("treble", "D", 4),
+ caption: [D Major Scale]
+)
+
+#figure(
+ arpeggio("bass", "g", 2, note-duration: "crotchet"),
+ caption: [G Minor Arpeggio]
+)
+
+
+#figure(
+ stave("alto", "c", notes: ("C3", "D#4", "F3")),
+ caption: [Custom Notes]
+)
+
+
+== Stave
+
+The foundational function is called `stave`.
+This is for writing just clefs, clefs and key signatures, or clefs, key signatures and custom notes.
+
+=== Usage
+
+The arguments are:
+
+#let kwarg_defs = (
+ "geometric-scale": [(optional) Number e.g. 0.5 or 2 to draw the content at half or double the size. This is about visual scale, not musical scales.],
+ "note-duration": [(optional) Allowed values are "#all-note-durations.join("\", \"")". Default is "whole" note. All notes are the same duration.],
+ "note-sep": [(optional) Used to adjust the horizontal spacing between notes. If you shrink below `note-sep: 0.7`, leger lines will overlap. At that point if it's still too big, use `geometric-scale` as well.],
+ "equal-note-head-space": [`true` or `false`. Defaults to `true`. If true, note heads will be equally spaced. Some of this space will be taken up with accidentals. If `false`, adding an accidental to a note will shift the note head further right. `true` looks better (in my opinion), but `false` is useful in combination with the other spacing arguments, to avoid accidentals overlapping with previous note heads.]
+)
+
+/ `clef`: Allowed values are "#all-clefs.join("\", \"")". Drawing a treble clef above a bass clef, linked as a double-stave (like for a piano) is not yet supported.
+/ `key`: Two possible forms.
+ - Letter based: Uppercase for major, lowercase for minor, with `#` or `b` appended. e.g. `"C"`, `"Db"`, `"f#"`
+ - Number based, with a symbol: "5\#" (or "5s") for 5 sharps, "2b" for 2 flats
+/ `notes`: An (optional) array of strings representing notes to play sequentially. Chords are not supported. e.g.
+ - "C4" is middle C
+ - "C5" is the C an octave above middle C.
+ - "Db4" or "C\#4" is a semitone above middle C
+ - "B3" is a semitone below middle C
+ - "Bn3" has an explicit natural accidental ♮ infront of it
+ - "Fx3" is an F3 with a double sharp, drawn as an #box(height: 0.7em, image(_symbol-data.double-sharp-x.image)) (Formats such as "F\#\#3" to show ♯♯ are not supported yet.)
+ - double flats are not yet supported.
+#for (k, v) in kwarg_defs.pairs(){
+ [
+ / #raw(k): #v
+ ]
+}
+
+=== Examples
+
+To draw just a key signature, omit the `notes` argument
+
+```typst
+#import "@preview/staves:0.1.0": stave
+
+#figure(
+ stave("treble", "D"),
+ caption: [D Major Key Signature]
+)
+```
+
+#figure(
+ stave("treble", "D"),
+ caption: [D Major Key Signature]
+)
+
+
+Here is an example of including `notes`. Legerlines are supported.
+
+```typst
+#figure(
+ stave("treble", "F", notes: ("F4", "A4", "C5", "F5", "C5", "A4", "F4")),
+ caption: [F Major Arpeggio]
+)
+```
+
+#figure(
+ stave("treble", "F", notes: ("F4", "A4", "C5", "F5", "A5", "C6", "F6", "C6", "A5", "F5", "C5", "A4", "F4")),
+ caption: [F Major Arpeggio]
+)
+
+Note that accidentals are independent of the key signature.
+For the example of F major, the key contains B flat. A "B" note will be drawn with no accidental, so it is flattenned by the key signature. A "Bb" will have a redundant flat accidental drawn. "Bn" will have an explicit natural accidental.
+This behavior may change in future versions.
+
+
+```typst
+#figure(
+ stave("bass", "F", notes: ("C2", "B2", "Bb2", "B2", "Bn2")),
+ caption: [Lack of interaction between accidentals and key signature]
+)
+```
+
+#figure(
+ stave("bass", "F", notes: ("C2", "B2", "Bb2", "B2", "Bn2")),
+ caption: [Lack of interaction between accidentals and key signature]
+)
+
+The `note-duration` can be used to change the note symbol.
+
+#let canvases = ()
+#for note-duration in all-note-durations {
+ canvases.push([
+ #figure(
+ stave("treble", "C", notes: ("C5", "B4", "A4"), note-duration: note-duration),
+ caption: [`note-duration`: #note-duration]
+ )
+ ])
+}
+
+
+#grid(
+ columns: (1fr, 1fr),
+ column-gutter: 1em,
+ row-gutter: 1em,
+ align: horizon,
+ ..canvases
+)
+
+=== Spacing and Sizing
+
+The `geometric-scale` argument can be used to adjust the overall size:
+
+
+#grid(
+ columns: (2fr, 1fr, 1fr),
+ column-gutter: 1em,
+ row-gutter: 1em,
+ align: horizon,
+ figure(
+ stave("bass", "F", notes: ("C#3",), geometric-scale: 2),
+ caption: [`geometric-scale: 2`]
+ ),
+ figure(
+ stave("bass", "F", notes: ("C#3",)),
+ caption: [default (omitted `geometric-scale`)]
+ ),
+ figure(
+ stave("bass", "F", notes: ("C#3",), geometric-scale: 0.5),
+ caption: [`geometric-scale: 0.5`]
+ )
+)
+
+`note-sep` can be used to adjust the horizontal separation between notes, whilst keeping the height of the stave the same:
+
+#grid(
+ columns: (1fr, 1fr),
+ column-gutter: 1em,
+ row-gutter: 1em,
+ align: horizon,
+ figure(
+ stave("bass", "G", notes: ("C3", "D3", "C3")),
+ caption: [default (omitted `note-sep`)]
+ ),
+ figure(
+ stave("bass", "G", notes: ("C3", "D3", "C3"), note-sep: 0.6),
+ caption: [`note-sep: 0.7`]
+ )
+)
+
+`equal-note-head-space` is used to adjust the spacing based on whether there are accidentals.
+
+#let canvases = ()
+
+#for e in (true, false) {
+ canvases.push(
+ figure(
+ stave("treble", "C", notes: ("C5", "C#5", "D5", "D#5"), equal-note-head-space: e),
+ caption: [`equal-note-head-space` = #e]
+ )
+ )
+}
+
+
+#grid(
+ columns: (1fr, 1fr),
+ column-gutter: 1em,
+ row-gutter: 1em,
+ align: horizon,
+ ..canvases
+)
+
+
+== Major Scales
+
+The `major-scale` function is for writing major scales.
+
+=== Usage
+
+/ `clef`: Allowed values are "#all-clefs.join("\", \"")". (Same as for `stave`.)
+/ `key`: e.g. "A", "Bb", "C\#". Uppercase only.
+/ `start-octave`: integer. e.g. 4 is the octave starting from middle C. 5 is the octave above that.
+/ `num-octaves`: Optional, defaults to 1.
+#for (k, v) in kwarg_defs.pairs(){
+ [
+ / #raw(k): #v
+ ]
+}
+
+=== Examples
+
+```typst
+#import "@preview/staves:0.1.0": major-scale
+
+#figure(
+ major-scale("treble", "D", 4),
+ caption: [D Major scale]
+)
+```
+
+#figure(
+ major-scale("treble", "D", 4),
+ caption: [D Major scale]
+)
+
+You can write a 2 octave scale with `num-octaves: 2`.
+This is probably too wide for your page. Shrink it horizontally with `note-sep`, or shrink in both dimensions with `geometric-scale`.
+
+```typst
+#figure(
+ major-scale("bass", "F", 2, num-octaves: 2, note-sep: 0.7, geometric-scale: 0.7),
+ caption: [F Major scale]
+)
+```
+
+#figure(
+ major-scale("bass", "F", 2, num-octaves: 2, note-sep: 0.7, geometric-scale: 0.7),
+ caption: [F Major scale]
+)
+
+== Minor Scale
+
+The `minor-scale` function is for writing natural and harmonic minor scales.
+The usage is the same as for `major-scale`, plus an additional `minor-type` argument.
+
+
+=== Usage
+
+/ `clef`: Allowed values are "#all-clefs.join("\", \"")". (Same as for `stave`.)
+/ `key`: e.g. "A", "Bb", "c\#". Uppercase or lowercase.
+/ `start-octave`: integer. e.g. 4 is the octave starting from middle C. 5 is the octave above that.
+/ `num-octaves`: Optional, defaults to 1.
+/ `minor-type`: Defaults to "harmonic". Allowed values are "#_minor-types.join("\", \"")". Melodic minor scales are not yet supported.
+#for (k, v) in kwarg_defs.pairs(){
+ [
+ / #raw(k): #v
+ ]
+}
+
+=== Examples
+
+
+```typst
+#import "@preview/staves:0.1.0": minor-scale
+
+#figure(
+ minor-scale("treble", "D", 4),
+ caption: [D Harmonic Minor scale]
+)
+```
+
+#figure(
+ minor-scale("treble", "D", 4),
+ caption: [D Harmonic Minor scale]
+)
+
+
+```typst
+#import "@preview/staves:0.1.0": minor-scale
+
+#figure(
+ minor-scale("bass", "Bb", 2, minor-type: "natural"),
+ caption: [Bb Natural Minor scale]
+)
+```
+
+#figure(
+ minor-scale("bass", "Bb", 2, minor-type: "natural"),
+ caption: [Bb Natural Minor scale]
+)
+
+
+Note that for keys with a sharp, the raised 7th can be written as a double sharp, or a natural of the next note.
+
+```typst
+#figure(
+ minor-scale("treble", "F#", 4, seventh: "n"),
+ caption: [F\# Harmonic Minor scale with 7th written as F natural]
+)
+
+#figure(
+ minor-scale("treble", "F#", 4, seventh: "x"),
+ caption: [F\# Harmonic Minor scale with 7th written as E double sharp]
+)
+```
+
+#figure(
+ minor-scale("treble", "F#", 4, seventh: "n"),
+ caption: [F\# Harmonic Minor scale with 7th written as F natural]
+)
+
+#figure(
+ minor-scale("treble", "F#", 4, seventh: "x"),
+ caption: [F\# Harmonic Minor scale with 7th written as E double sharp]
+)
+
+== Arpeggio
+
+The `arpeggio` function is for writing arpeggios.
+
+=== Usage
+
+The arguments are the same as for `major-scale`.
+
+
+/ `clef`: Allowed values are "#all-clefs.join("\", \"")". (Same as for `stave`.)
+/ `key`: e.g. "A", "Bb", "C\#". Uppercase for major, lowercase for minor. Do not include a number for the octave.
+/ `start-octave`: integer. e.g. 4 is the octave starting from middle C. 5 is the octave above that.
+/ `num-octaves`: Optional, defaults to 1.
+#for (k, v) in kwarg_defs.pairs(){
+ [
+ / #raw(k): #v
+ ]
+}
+
+=== Example
+
+```typst
+#import "@preview/staves:0.1.0": arpeggio
+
+#figure(
+ arpeggio("bass", "F", 2, num-octaves: 2),
+ caption: [F Major Arpeggio]
+)
+```
+#figure(
+ arpeggio("bass", "F", 2, num-octaves: 2),
+ caption: [F Major Arpeggio]
+)
+
+== Chromatic Scales
+
+`chromatic-scale` is used to write chromatic scales (every semitone between two notes).
+The arguments are:
+
+=== Usage
+
+/ `clef`: Allowed values are "#all-clefs.join("\", \"")". (Same as for `stave`.)
+/ `start-note`: e.g. "C4" for middle C, "C5" for the C above that, "Db4" for a semitone above middle C
+/ `num-octaves`: Optional, defaults to 1.
+/ `side`: "#_allowed-sides.join("\", \"")"
+#for (k, v) in kwarg_defs.pairs(){
+ [
+ / #raw(k): #v
+ ]
+}
+
+These scales tend to be quite long, so you probably want to use `note-sep` and `geometric-scale`, and perhaps a landscape page.
+
+=== Examples
+
+```typst
+#import "@preview/staves:0.1.0": chromatic-scale
+
+#figure(
+ chromatic-scale("treble", "D4", note-sep: 0.8, geometric-scale: 0.7),
+ caption: [D Chromatic Scale]
+)
+```
+
+#figure(
+ chromatic-scale("treble", "D4", note-sep: 0.8, geometric-scale: 0.7),
+ caption: [D Chromatic Scale]
+)
+
+```typst
+#figure(
+ chromatic-scale("bass", "F2", side: "flat", geometric-scale: 0.6, note-duration: "crotchet"),
+ caption: [F Chromatic Scale]
+)
+```
+
+#figure(
+ chromatic-scale("bass", "F2", side: "flat", geometric-scale: 0.6, note-duration: "crotchet"),
+ caption: [F Chromatic Scale]
+)
+
+== Implementation Details
+
+This package uses a `canvas` from the #link("https://typst.app/universe/package/cetz", "CeTZ") package.
+
+== License Details
+
+This library uses SVG images for clefs, accidentals etc.
+These files came from Wikipedia, and are in the public domain.
+They are not covered by the same license as the rest of the package.
+Source URLs for these SVGs are listed in #link("https://github.com/mdavis-xyz/staves-typst/tree/master/assets", `/assets/README.md`)
\ No newline at end of file
diff --git a/packages/preview/staves/0.1.0/src/README.md b/packages/preview/staves/0.1.0/src/README.md
new file mode 100644
index 0000000000..a32c93c4c6
--- /dev/null
+++ b/packages/preview/staves/0.1.0/src/README.md
@@ -0,0 +1,94 @@
+# Implementation
+
+## Files
+
+* `data.typ` contains config and hard-coded values relating to musical scales and SVG images
+* `core.typ` contains the implementation of the final functions that get exposed (`stave`, `major-scale` etc)
+* `utils.typ` contains helper functions calculating things, e.g. add N semitones to a given note
+* `lib.typ` is a stub that just selectively imports only what we want to export
+* `test.typ` contains unit tests for calculations in `utils.typ`
+
+## Coordinates
+
+`y` height is defined as:
+
+- 0 = bottom line
+- y=1, second-bottom line
+- y=1.5 between 2nd-bottom and middle line
+
+`x=0` is the left end of the bottom line. Positive `x` towards the right.
+
+## Data Structures
+
+We represent notes (at a specific octave) either as letter-based or integer-based.
+
+### Letter Based
+
+
+
+```
+(
+ type: "letter-note",
+ letter: letter, // always uppercase
+ accidental: accidental, // none, "n", "#", or "b"
+ octave: octave
+)
+```
+
+Where middle C is:
+
+```
+(
+ type: "letter-note",
+ letter: "C",
+ accidental: none,
+ octave: 4
+)
+```
+
+The octave boundaries are between each C and the B below it. (Not between A and G).
+
+### Index Based
+
+```
+(
+ type: "index-note",
+ index: int,
+ side: "flat" or "sharp"
+)
+```
+
+e.g. middle C is:
+
+```
+(
+ type: "index-note",
+ index: 60,
+ side: "flat" // could be either
+)
+```
+
+e.g. A semitone above middle C is either C#:
+
+```
+(
+ type: "index-note",
+ index: 61,
+ side: "sharp"
+)
+```
+
+or Db
+
+```
+(
+ type: "index-note",
+ index: 61,
+ side: "flat"
+)
+```
+
+## Scale Arithmetic
+
+For calculating which notes to put into scales, we trust that the key signature handles most accidentals, and generally just increment the letter.
+Accidentals for minor scales are handled based on intervals to the top root note, without explicit consideration of the key signature.
diff --git a/packages/preview/staves/0.1.0/src/core.typ b/packages/preview/staves/0.1.0/src/core.typ
new file mode 100644
index 0000000000..374a622e28
--- /dev/null
+++ b/packages/preview/staves/0.1.0/src/core.typ
@@ -0,0 +1,355 @@
+#import "@preview/cetz:0.3.4"
+
+#import "data.typ": *
+#import "utils.typ": *
+
+
+#let stave(clef, key, notes: (), geometric-scale: 1, note-duration: "semibreve", note-sep: 1, equal-note-head-space: false) = {
+
+ // validate arguments
+ assert(clef in clef-data.keys(),
+ message: "Invalid clef argument. Must be " + clef-data.keys().map(str).join(", "))
+
+ assert(note-duration in note-duration-data.keys(),
+ message: "Invalid note-duration argument. Must be " + note-duration-data.keys().map(str).join(", "))
+
+ let result = determine-key(key)
+ let num-chromatics = result.num-chromatics
+ let symbol-type = result.symbol-type
+
+
+ cetz.canvas(length: geometric-scale * 0.3cm, {
+ import cetz.draw: line, content, rect
+
+ let leger-line-width = 1.8
+ let accidental-offset = leger-line-width / 2 + 0.35 // accidentals are this far left of notes
+ let stem-length = 3 + 0.2 // don't make it an integer, it looks bad
+
+
+ // note that x = x + 1
+ // does not work in typst
+ // so we do xs = (1, 2, 1)
+ // and sum that array each time
+ let clef-center-x = 2
+ let xs = (clef-center-x, )
+
+
+ let y = clef-data.at(clef).at("clef").at("y-offset")
+
+ // clef
+ content((xs.sum(), y ), [
+ #image(clef-data.at(clef).at("clef").at("image"), height: (clef-data.at(clef).at("clef").at("y-span") ) * geometric-scale * 1cm)
+ ], anchor: "center")
+
+ xs.push(3)
+
+ // key signature
+ for i in range(num-chromatics) {
+ assert(symbol-type != "natural", message: "natural in key signature? " + key + " " + str(num-chromatics) + " " + symbol-type)
+ let y = clef-data.at(clef).at("accidentals").at(symbol-type).at(i)
+
+ content((xs.sum() , (y + symbol-data.at(symbol-type).at("y-offset")) ), [
+ #image(symbol-data.at(symbol-type).at("image"),
+ height: (symbol-data.at(symbol-type).at("y-span") ) * geometric-scale * 1cm)
+ ])
+ xs.push(1)
+ }
+
+ // if there were any sharps or flats (non-empty key signature),
+ // then add some extra space
+ if num-chromatics > 0 {
+ xs.push(1)
+ }
+
+ // draw our notes
+ for (i, note-str) in notes.enumerate() {
+
+ let note = parse-note-string(note-str)
+ let note-height = calc-note-height(clef, note)
+
+
+ let image-path = note-duration-data.at(note-duration).at("image")
+
+
+ // extra space if this is the first note and it has an accidental
+ if (note.accidental != none) and (i == 0) {
+ xs.push(1)
+ }
+
+
+ let y = note-height + note-duration-data.at(note-duration).at("y-offset")
+
+ // accidentals
+ if note.accidental != none {
+ let symbol-name = symbol-map.at(note.accidental)
+ assert(symbol-name in symbol-data, message: "unknown symbol " + note.accidental + " with name " + symbol-name)
+ let accidental = symbol-data.at(symbol-name)
+
+ if equal-note-head-space {
+ xs.push(- accidental-offset)
+ }
+ let x = xs.sum()
+
+ content(((x) , (y + accidental.y-offset) ), [
+ #image(accidental.image,
+ height: (accidental.y-span ) * geometric-scale * 1cm)
+ ])
+ xs.push(accidental-offset)
+
+ }
+
+ let x = xs.sum()
+ let y-span = note-duration-data.at(note-duration).at("y-span")
+
+
+ // leger lines
+ let left-x = x - leger-line-width / 2
+ let right-x = left-x + leger-line-width
+ if note-height <= -1 {
+ // leger lines below
+ let leger-start = calc.trunc(note-height)
+ let leger-end = -1
+ for h in range(leger-start, leger-end + 1) {
+ line((left-x , h ), (right-x , h ))
+ }
+ } else if note-height >= 5 {
+ // leger lines above
+ let leger-start = 5
+ let leger-end = calc.trunc(note-height)
+ for h in range(leger-start, leger-end + 1) {
+ line((left-x , h ), (right-x , h ))
+ }
+ }
+
+ // note head
+ content((x , y ), [
+ #image(image-path,
+ height: (y-span ) * geometric-scale * 1cm)
+ ])
+
+ // stem
+ if note-duration-data.at(note-duration).at("stem") {
+ let head-width = note-duration-data.at(note-duration).at("width")
+ if y < 2 {
+ // stem up on right
+ line(
+ (
+ (x + head-width * 0.5) ,
+ y
+ ),
+ (
+ (x + head-width * 0.5) ,
+ (y + stem-length)
+ )
+ )
+ } else {
+ // stem down on left
+ line(
+ (
+ (x - head-width * 0.5) ,
+ y
+ ),
+ (
+ (x - head-width * 0.5) ,
+ (y - stem-length)
+ )
+ )
+ }
+
+ }
+
+
+ if (i <= notes.len() - 2) {
+ xs.push(note-sep * 3) // space between notes
+ }
+
+ }
+
+ xs.push(2)
+
+
+
+ if notes.len() > 0 {
+ // double bar at end
+ let double-bar-sep = 0.5
+ let double-bar-thick = 0.15
+ let x = xs.sum()
+ line((x, 0), (x, 4 ))
+ xs.push(double-bar-sep + double-bar-thick)
+ let x = xs.sum()
+ rect((x, 0), (x - double-bar-thick, 4 ), fill: black)
+ }
+
+ // draw the 5 stave lines
+ // left until last because only now do we know the total width
+ let x = xs.sum()
+ for i in range(5) {
+ line((0, i ), (x, i ))
+ }
+
+ })
+}
+
+
+#let arpeggio(clef, key, start-octave, num-octaves: 1, ..kwargs) = {
+ // remove flat/sharp from key, increment letters and octaves
+ // assume the key signature will handle flats/sharps for us
+ let notes = ()
+ let start-note = remove-accidental(parse-note-string(key + str(start-octave)))
+
+ // ascent
+ for ov in range(num-octaves) {
+ // root
+ notes.push(shift-octave(start-note, ov))
+
+ // third
+ notes.push(increment-wholenote(notes.at(-1), steps: 2))
+
+
+ // fifth
+ notes.push(increment-wholenote(notes.at(-1), steps: 2))
+
+ }
+
+ // peak
+ let peak = shift-octave(start-note, num-octaves)
+
+ let notes = notes + (peak, ) + notes.rev()
+
+ stave(clef, key, notes: notes, ..kwargs)
+}
+
+#let major-scale(clef, key, start-octave, num-octaves: 1, ..kwargs) = {
+ // remove flat/sharp from key, increment letters and octaves
+ // assume the key signature will handle flats/sharps for us
+
+ assert(key.at(0) == upper(key.at(0)), message: "key must be uppercase for major-scale")
+ let start-note = remove-accidental(parse-note-string(key + str(start-octave)))
+ let notes = (start-note, )
+
+ // ascent
+ for i in range(num-octaves * num-letters-per-octave) {
+ notes.push(increment-wholenote(notes.at(-1)))
+ }
+
+ let peak = notes.pop()
+
+ let notes = notes + (peak, ) + notes.rev()
+
+ stave(clef, key, notes: notes, ..kwargs)
+}
+
+
+#let minor-types = ("natural", "harmonic")
+#let seventh-types = ("n", "x")
+// harmonic minor by default
+// let the key signature handle sharps/flats
+// just increment letters
+// then handle the 7th based on the root note, not the key
+#let minor-scale(clef, key, start-octave, num-octaves: 1, minor-type: "harmonic", seventh: "n", ..kwargs) = {
+ // remove flat/sharp from key, increment letters and octaves
+ // assume the key signature will handle flats/sharps for us
+
+ assert(minor-type in minor-types, message: "minor-type must be one of " + minor-types.join(", "))
+ assert(seventh in seventh-types, message: "seventh argument must be one of " + seventh-types.join(", "))
+
+ if key.at(0) == upper(key.at(0)) {
+ // set first character to uppercase
+ let new-key = lower(key.at(0)) + key.slice(1)
+ return minor-scale(clef, new-key, start-octave, num-octaves: 1, minor-type: minor-type, seventh: seventh, ..kwargs)
+ }
+
+ let start-note-raw = parse-note-string(key + str(start-octave))
+ let key-accidental = start-note-raw.accidental
+ let start-note = remove-accidental(start-note-raw)
+ let notes = ()
+
+ // ascent
+ for ov in range(num-octaves) {
+ // root
+ let root-note = shift-octave(start-note, ov)
+ notes.push(root-note)
+ for i in range(1, num-letters-per-octave) {
+ let n = increment-wholenote(notes.at(-1))
+ if (minor-type == "harmonic") and (calc.rem-euclid(i, num-letters-per-octave) == num-letters-per-octave - 1) {
+ // handle the 7th specially, to flatten it
+ if key-accidental in (none, "n") {
+ if upper(key.at(0)) in ("C", "F") {
+ // undo a flat for these special cases
+ notes.push(set-accidental(n, "n"))
+ } else {
+ // 7th note will be a sharp
+ notes.push(set-accidental(n, "#"))
+ }
+ } else if key-accidental == "b" {
+ // 7th note will be a natural accidental, with a flat in the key signature
+ // add it with an explicit natural
+ notes.push(set-accidental(n, "n"))
+ } else {
+ assert(key-accidental in ("s", "#"))
+ if seventh == "n" {
+ // 7th note will be a double-sharp
+ // use the root note with an explicit natural
+ notes.push(set-accidental(shift-octave(root-note, 1), "n"))
+ start-note = set-accidental(start-note, "#")
+ } else {
+ assert(seventh == "x")
+ notes.push(set-accidental(n, "x"))
+ }
+
+ }
+ assert(minor-type == "harmonic", message: "minor-type: " + minor-type)
+ } else {
+ notes.push(n)
+ }
+ }
+ }
+
+ let peak = shift-octave(start-note, num-octaves)
+
+ let notes = notes + (peak, ) + notes.rev()
+
+ if notes.at(0) != start-note {
+
+ // if we have a double-sharp 7th, then accidental for the root
+ // add an accidental for the last note
+ // (root, with no prior accidental in that octave)
+ notes.push(set-accidental(notes.pop(), "#"))
+ }
+
+ stave(clef, key, notes: notes, ..kwargs)
+}
+
+#let chromatic-scale(clef, start-note, num-octaves: 1, side: "sharp", ..kwargs) = {
+ assert(side in allowed-sides, message: "side argument must be in : " + allowed-sides.join(", "))
+ let start-note-parsed = parse-note-string(start-note)
+ let notes = ()
+
+ // ascent
+ for i in range(12 * num-octaves + 1) {
+ let note = add-semitones(start-note-parsed.letter, start-note-parsed.accidental, start-note-parsed.octave, steps: i, side: side)
+ let a = if note.accidental == none {
+ ""
+ } else {
+ note.accidental
+ }
+ notes.push(
+ note.letter + a + str(note.octave)
+ )
+ }
+
+ // descent
+ for i in range(12 * num-octaves - 1 , 0 - 1, step: -1) {
+ let note = add-semitones(start-note-parsed.letter, start-note-parsed.accidental, start-note-parsed.octave, steps: i, side: side)
+ let a = if note.accidental == none {
+ ""
+ } else {
+ note.accidental
+ }
+ notes.push(
+ note.letter + a + str(note.octave)
+ )
+ }
+
+ stave(clef, "C", notes: notes, ..kwargs)
+}
diff --git a/packages/preview/staves/0.1.0/src/data.typ b/packages/preview/staves/0.1.0/src/data.typ
new file mode 100644
index 0000000000..9648736aab
--- /dev/null
+++ b/packages/preview/staves/0.1.0/src/data.typ
@@ -0,0 +1,149 @@
+
+// clef-data contains information about clefs:
+// the symbols, as well as which line is which
+// and where the key signature symbols go
+//
+// reference for key order:
+// sharps: https://music-theory-practice.com/images/order-of-sharps-staves.png
+// flats: https://music-theory-practice.com/images/order-of-flats-staves.jpeg
+#let clef-data = (
+ treble: (
+ clef: (
+ image: "/assets/clefs/treble.svg",
+ y-offset: 2,
+ y-span: 2,
+ ),
+ middle-c: -1,
+ accidentals: (
+ sharp: (4, 2.5, 3.5, 2, 3, 1.5, 2.5),
+ flat: (2, 3.5, 1.5, 3, 1, 2.5, 0.5)
+ )
+ ),
+ bass: (
+ clef: (
+ image: "/assets/clefs/bass.svg",
+ y-offset: 2.4,
+ y-span: 1,
+ ),
+ middle-c: 5,
+ accidentals: (
+ sharp: (3, 1.5, 3.5, 2, 4, 2.5, 4.5),
+ flat: (1, 2.5, 0.5, 2, 0, 1.5, -0.5)
+ )
+ ),
+ alto: (
+ clef: (
+ image: "/assets/clefs/alto.svg",
+ y-offset: 2,
+ y-span: 1.2,
+ ),
+ middle-c: 2,
+ accidentals: (
+ sharp: (3.5, 2, 4, 2.5, 1, 3, 1.5),
+ flat: (1.5, 3, 1, 2.5, 0.5, 2, 0)
+ )
+ ),
+ tenor: (
+ clef: (
+ image: "/assets/clefs/alto.svg",
+ y-offset: 3,
+ y-span: 1.2,
+ ),
+ middle-c: 3,
+ accidentals: (
+ sharp: (1, 3, 1.5, 3.5, 2, 4, 2.5),
+ flat: (2.5, 4, 2, 3.5, 1.5, 3, 1)
+ )
+ ),
+)
+
+// this is data about accidental/chromatic icons
+#let symbol-data = (
+ sharp: (
+ image: "/assets/accidental/sharp.svg",
+ y-offset: 0,
+ y-span: 0.8
+ ),
+ flat: (
+ image: "/assets/accidental/flat.svg",
+ y-offset: 0.4,
+ y-span: 0.6
+ ),
+ natural: (
+ image: "/assets/accidental/natural.svg",
+ y-offset: 0,
+ y-span: 0.8
+ ),
+ double-sharp-x: (
+ image: "/assets/accidental/double-sharp-x.svg",
+ y-offset: 0,
+ y-span: 0.3
+ )
+)
+
+
+// write out the circle of fifths
+// from 7 flats, to C (nothing) to 7 sharps
+// correctness reference:
+// https://www.music-theory-for-musicians.com/staves.html
+#let key-data = (
+ major: (
+ "Cb", "Gb", "Db", "Ab", "Eb", "Bb", "F",
+ "C",
+ "G", "D", "A", "E", "B", "F#", "C#"
+ ),
+ minor: (
+ "ab", "eb", "bb", "f", "c", "g", "d",
+ "a",
+ "e", "b", "f#", "c#", "g#", "d#", "a#"
+
+ )
+)
+
+#let symbol-map = (
+ "#": "sharp",
+ "s": "sharp",
+ "b": "flat",
+ "n": "natural",
+ "x": "double-sharp-x",
+)
+
+// for the note (heads) themselves
+#let note-duration-data = (
+ whole: (
+ image: "/assets/notes/whole.svg",
+ y-offset: 0,
+ y-span: 0.3,
+ stem: false
+ ),
+ quarter: (
+ // without stem
+ image: "/assets/notes/crotchet-head.svg",
+ y-offset: 0,
+ y-span: 0.3,
+ width: 1.08,
+ stem: true
+ )
+)
+
+// add aliases
+#note-duration-data.insert("semibreve", note-duration-data.at("whole"))
+#note-duration-data.insert("crotchet", note-duration-data.at("quarter"))
+
+#let semitones-per-octave = 12
+
+#let middle-c-octave = 4
+#let middle-c-index = 60
+#let all-notes-from-c = (
+ sharp: ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"),
+ flat: ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
+)
+#let all-letters-from-c = ("C", "D", "E", "F", "G", "A", "B")
+#let num-letters-per-octave = all-letters-from-c.len()
+
+#let allowed-sides = ("sharp", "flat") // not plural
+
+// export some dict keys for documentation and testing
+#let all-clefs = clef-data.keys()
+#let all-note-durations = note-duration-data.keys()
+#let all-symbols = symbol-map.keys()
\ No newline at end of file
diff --git a/packages/preview/staves/0.1.0/src/lib.typ b/packages/preview/staves/0.1.0/src/lib.typ
new file mode 100644
index 0000000000..50ccae6d0c
--- /dev/null
+++ b/packages/preview/staves/0.1.0/src/lib.typ
@@ -0,0 +1,3 @@
+#import "core.typ": stave, major-scale, minor-scale, arpeggio, chromatic-scale, minor-types as _minor-types
+
+#import "data.typ": all-clefs, all-note-durations, all-symbols as _all-symbols, key-data as _key-data, allowed-sides as _allowed-sides, symbol-data as _symbol-data
diff --git a/packages/preview/staves/0.1.0/src/test.typ b/packages/preview/staves/0.1.0/src/test.typ
new file mode 100644
index 0000000000..cfdda9d299
--- /dev/null
+++ b/packages/preview/staves/0.1.0/src/test.typ
@@ -0,0 +1,297 @@
+#import "utils.typ": *
+#import "data.typ": *
+#import "core.typ": *
+
+= Code Tests (Assertions)
+
+#let test-is-integer() = {
+ assert(is-integer("0"))
+ assert(not is-integer("A"))
+ assert(not is-integer(""))
+ assert(not is-integer(none))
+
+}
+
+#let test-determine-key() = {
+ // key can be:
+// C -> C Major
+// Bb -> Bb Major
+// a# -> A# Minor
+// bb -> Bb Minor
+// none -> C Major
+// "" (empty) -> C Major
+// "3b" -> 3 flats (Eb Major)
+
+ assert(determine-key("C").num-chromatics == 0)
+ assert(determine-key("").num-chromatics == 0)
+ assert(determine-key(none).num-chromatics == 0)
+ assert(determine-key("Bb") == (num-chromatics: 2, symbol-type: "flat"))
+ assert(determine-key("a#") == (num-chromatics: 7, symbol-type: "sharp"))
+ assert(determine-key("3b") == (num-chromatics: 3, symbol-type: "flat"))
+}
+
+#let test-parse-note-string() = {
+ assert(parse-note-string("Cb5") == letter-note("C", 5, accidental: "b"))
+ assert(parse-note-string("A6") == letter-note("A", 6))
+}
+
+#let test-serialise-note() = {
+ assert(serialise-note(letter-note("C", 5, accidental: "b")) == "Cb5")
+ assert(serialise-note(letter-note("C", 5, accidental: "n"), suppress-natural: true) == "C5")
+ assert(serialise-note(letter-note("C", 5, accidental: "n"), suppress-natural: false) == "Cn5")
+}
+
+#let test-accidental-string() = {
+ assert(accidental-string("A", none) == "A")
+ assert(accidental-string("B", "#") == "B#")
+ assert(accidental-string("C", "n", suppress-natural: false) == "Cn")
+ assert(accidental-string("D", "n", suppress-natural: true) == "D")
+}
+
+#let test-letter-to-index() = {
+ assert(letter-to-index(letter-note("C", 4, accidental: none), "sharp").index == middle-c-index)
+ assert(letter-to-index(letter-note("C", 5, accidental: none), "sharp").index == middle-c-index + semitones-per-octave)
+ assert(letter-to-index(letter-note("C", 3, accidental: none), "sharp").index == middle-c-index - semitones-per-octave)
+ assert(letter-to-index(letter-note("C", 4, accidental: "s"), "sharp").index == middle-c-index + 1)
+ assert(letter-to-index(letter-note("B", 3, accidental: "n"), "sharp").index == middle-c-index - 1)
+}
+
+#let test-index-to-letter() = {
+ let i = index-note(middle-c-index, "sharp")
+ assert(i.index == middle-c-index)
+ let actual = index-to-letter(i)
+
+ assert(actual.letter == "C")
+ assert(actual.accidental == none)
+ assert(actual.octave == middle-c-octave)
+
+ let expected = letter-note("C", 4, accidental: none)
+ assert(actual == expected, message: serialise-note(actual) + " != " + serialise-note(expected))
+
+
+ let i = index-note(middle-c-index + 1, "sharp")
+ let actual = index-to-letter(i)
+ assert(actual.letter == "C")
+ assert(actual.accidental == "#")
+ assert(actual.octave == middle-c-octave)
+
+ let expected = letter-note("C", 4, accidental: "#")
+ assert(actual == expected, message: serialise-note(actual) + " != " + serialise-note(expected))
+
+
+ let i = index-note(middle-c-index - 2, "flat")
+ let actual = index-to-letter(i)
+ assert(actual.letter == "B", message: "actual.letter is " + actual.letter)
+ assert(actual.accidental == "b")
+ assert(actual.octave == middle-c-octave - 1)
+
+ let expected = letter-note("B", 3, accidental: "b")
+ assert(actual == expected, message: serialise-note(actual) + " != " + serialise-note(expected))
+
+
+
+ let i = index-note(middle-c-index + 3 + semitones-per-octave, "sharp")
+ let actual = index-to-letter(i)
+ assert(actual.letter == "D", message: "actual.letter is " + actual.letter)
+ assert(actual.accidental == "#")
+ assert(actual.octave == middle-c-octave + 1)
+
+ let expected = letter-note("D", 5, accidental: "#")
+ assert(actual == expected, message: serialise-note(actual) + " != " + serialise-note(expected))
+
+
+}
+
+#let test-calc-note-height() = {
+ assert(calc-note-height("treble", letter-note("C", 4)) == -1)
+ assert(calc-note-height("treble", letter-note("F", 4)) == 0.5)
+ assert(calc-note-height("treble", letter-note("B", 4)) == 2)
+}
+
+
+#let test-increment-letter() = {
+ assert(increment-letter("A") == "B")
+ assert(increment-letter("B") == "C")
+ assert(increment-letter("G") == "A")
+}
+
+#let test-add-semitones() = {
+ let actual = add-semitones("C", none, 4, steps: 1, side: "sharp")
+ assert(actual.letter == "C")
+ assert(actual.accidental != none)
+ assert(actual.accidental == "#", message: "Got " + actual.accidental + " expected #")
+ assert(actual.octave == 4)
+}
+
+#let test-set-accidental() = {
+ assert(set-accidental(letter-note("C", 4), "#") == letter-note("C", 4, accidental: "#"))
+ assert(set-accidental(letter-note("D", 5), "b") == letter-note("D", 5, accidental: "b"))
+ assert(set-accidental(letter-note("F", 6), "n") == letter-note("F", 6, accidental: "n"))
+}
+
+#let test-increment-wholenote() = {
+ let n = letter-note("C", 4)
+ let expected = letter-note("D", 4)
+ let actual = increment-wholenote(n)
+ assert(expected == actual)
+
+ let expected = letter-note("E", 4)
+ let actual = increment-wholenote(n, steps: 2)
+ assert(expected == actual)
+
+ let expected = letter-note("C", 5)
+ let actual = increment-wholenote(n, steps: 7)
+ assert(expected == actual)
+
+ let expected = letter-note("D", 5)
+ let actual = increment-wholenote(n, steps: 8)
+ assert(expected == actual)
+
+ let expected = letter-note("B", 4)
+ let actual = increment-wholenote(n, steps: 6)
+ assert(expected == actual)
+
+ let n = letter-note("B", 6)
+ let expected = letter-note("D", 7)
+ let actual = increment-wholenote(n, steps: 2)
+ assert(expected == actual)
+}
+
+#let unit-test() = {
+ test-is-integer()
+ test-determine-key()
+ test-parse-note-string()
+ test-accidental-string()
+ test-letter-to-index()
+ test-serialise-note()
+ test-index-to-letter()
+ test-calc-note-height()
+ test-increment-letter()
+ test-add-semitones()
+ test-increment-wholenote()
+ test-set-accidental()
+}
+
+
+#unit-test()
+
+= Content Tests
+
+= Key Signature Tests
+
+Generate some staves of each type
+
+No symbols
+#stave("treble", "C")
+#stave("treble", "")
+#stave("treble", none)
+
+Major vs minor
+#stave("treble", "F")
+#stave("treble", "f")
+
+All symbols
+#for clef in all-clefs {
+ stave(clef, "C#")
+ stave(clef, "Cb")
+}
+
+Using numbers
+
+#stave("treble", "1#")
+#stave("treble", "1b")
+#stave("treble", "7#")
+#stave("treble", "7b")
+
+== Full Reference
+
+=== Numbers
+
+#let canvases = ()
+#for clef in all-clefs {
+ for num-symbols in range(0, 7) {
+ for symbol-char in all-symbols {
+ if symbol-char not in ("n", "x") {
+ let key = str(num-symbols) + symbol-char
+ canvases.push([
+ stave(#clef,#key)
+ #stave(clef, key)
+ ])
+ }
+ }
+ }
+}
+
+#grid(
+ columns: 4,
+ column-gutter: 1em,
+ row-gutter: 1em,
+ ..canvases
+)
+
+
+=== Letters
+
+#let canvases = ()
+#for clef in all-clefs {
+ for tonality in ("major", "minor") {
+ for key in key-data.at(tonality) {
+ canvases.push([
+ #stave(clef, key)
+ #clef #key #tonality
+ ])
+ }
+ }
+}
+
+#grid(
+ columns: 4,
+ column-gutter: 1em,
+ row-gutter: 1em,
+ ..canvases
+)
+
+= Notes too
+
+#stave("treble", "C", notes: ("C4", "Ds4", "E4", "F4", "G4", "A4", "B4", "C5"))
+
+#let canvases = ()
+
+#for clef in all-clefs {
+ stave(clef, "C", notes: ("C2", "C3", "C4", "C5", "C6"))
+}
+
+= Arpeggios
+
+#arpeggio(
+ "treble",
+ "D",
+ 5
+)
+
+
+#arpeggio(
+ "treble",
+ "D",
+ 5,
+ geometric-scale: 1.2,
+ note-duration: "crotchet"
+)
+
+
+= Minor Scales
+
+#for key in key-data.at("minor") {
+ for minor-type in minor-types {
+ for seventh in seventh-types {
+ figure(
+ minor-scale("treble", key, 4, minor-type: minor-type, seventh: seventh),
+ caption: [#key #minor-type Minor with seventh = #seventh]
+ )
+ }
+ }
+}
+
+= Double sharp
+
+#stave("treble", "C", notes: ("C5", "C#5", "Cx5"))
\ No newline at end of file
diff --git a/packages/preview/staves/0.1.0/src/utils.typ b/packages/preview/staves/0.1.0/src/utils.typ
new file mode 100644
index 0000000000..83cd787e4a
--- /dev/null
+++ b/packages/preview/staves/0.1.0/src/utils.typ
@@ -0,0 +1,321 @@
+#import "data.typ": *
+
+#let is-integer(char) = {
+ let digits = ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")
+ char in digits
+}
+
+// key can be:
+// C -> C Major
+// Bb -> Bb Major
+// a# -> A# Minor
+// bb -> Bb Minor
+// none -> C Major
+// "" (empty) -> C Major
+// "3b" -> 3 flats (Eb Major)
+#let determine-key(key) = {
+ if key == none or key == "" {
+ // empty key
+ (
+ num-chromatics: 0,
+ symbol-type: "sharp"
+ )
+ } else if is-integer(key.at(0)) {
+ // e.g. "5#"
+ let num-chromatics = int(key.at(0))
+ let symbol-char = key.at(1)
+ assert(symbol-char in symbol-map.keys(), message: "number-based key argument must end with " + symbol-data.keys().map(str).join(" or "))
+ let symbol-type = symbol-map.at(symbol-char)
+ (
+ num-chromatics: num-chromatics,
+ symbol-type: symbol-type
+ )
+ } else {
+ // e.g. "Fb"
+ let this-key-index
+ let mid-index
+
+ if key in key-data.major {
+ this-key-index = key-data.major.position(k => k == key)
+ mid-index = key-data.major.position(k => k == "C")
+ } else if key in key-data.minor {
+ this-key-index = key-data.minor.position(k => k == key)
+ mid-index = key-data.minor.position(k => k == "a")
+ } else {
+ panic("Invalid key: " + key)
+ }
+
+ let num-chromatics = calc.abs(this-key-index - mid-index)
+ let symbol-type = if this-key-index >= mid-index { "sharp" } else { "flat" }
+
+ (
+ num-chromatics: num-chromatics,
+ symbol-type: symbol-type
+ )
+ }
+}
+
+
+#let letter-note(letter, octave, accidental: none) = {
+ assert(type(octave) == int, message: "octave must be an integer. Did you mix up argument order for letter-note?")
+ (
+ type: "letter-note",
+ letter: letter,
+ accidental: accidental,
+ octave: octave
+ )
+}
+
+
+#let index-note(index, side) = {
+ (
+ type: "index-note",
+ index: index,
+ side: side
+ )
+}
+
+// takes in a string
+// returns a dictionary (letter-based note)
+// e.g. "Cb5" => {"letter": "C", "accidental": "b", octave: 5}
+// e.g. "A6" => {"letter": "C", "accidental": "n", octave: 5}
+// accidental defaults to none (distinct from "n" for natural)
+#let parse-note-string(note) = {
+ if type(note) == dictionary and note.type == "letter-note" {
+ return note
+ }
+ assert(type(note) == str, message: "invalid note data type")
+ if note.len() < 2 or note.len() > 3 {
+ panic("Invalid note format: " + note)
+ }
+
+ let letter = upper(note.at(0))
+ let octave-result = int(note.slice(-1))
+ if octave-result == none {
+ panic("Unable to parse octave integer from note: " + note)
+ }
+ let octave = octave-result
+
+ let accidental = if note.len() == 3 {
+ note.at(1)
+ } else {
+ none
+ }
+
+ if accidental != none {
+ assert(accidental in symbol-map.keys(), message: "Unable to parse note: " + note + ", unknown accidental " + accidental)
+ }
+
+ return letter-note(letter, octave, accidental: accidental)
+ (
+ type: "letter-note",
+ letter: letter,
+ accidental: accidental,
+ octave: octave
+ )
+}
+
+// from letter-note to string
+#let serialise-note(note, suppress-natural: false) = {
+ let a = if note.accidental == none {
+ ""
+ } else if suppress-natural and note.accidental == "n" {
+ ""
+ } else {
+ note.accidental
+ }
+ return note.letter + a + str(note.octave)
+}
+
+// concats letter and accidental
+// handling case where accidental: none
+// and optionally converting "n" -> ""
+#let accidental-string(letter, accidental, suppress-natural: false) = {
+ if (accidental == "n") and suppress-natural {
+ return accidental-string(letter, none)
+ } else if accidental == none {
+ return letter
+ } else if accidental == "s" {
+ // change from "s" to "#"
+ return accidental-string(letter, "#")
+ } else {
+ assert(accidental in symbol-map.keys(), message: "Unable to parse note: " + letter + ", unknown accidental " + accidental)
+ return letter + accidental
+ }
+}
+
+// takes in a letter-note
+// sets it to flat, sharp or natural
+#let set-accidental(note, accidental, overwrite-existing: false) = {
+ if not overwrite-existing {
+ assert(note.accidental == none, message: "would overwrite accidental in set-accidental without overwrite-existing")
+ }
+ return letter-note(note.letter, note.octave, accidental: accidental)
+}
+
+// convert from letter-note type to index-note type
+#let letter-to-index(note, side) = {
+ if note.at("type") == "index-note" {
+ // already the type we want
+ return note
+ }
+ assert(note.at("type") == "letter-note", message: "Unknown data type when converting to index-note")
+
+ let octaves-above-middle-c = note.octave - middle-c-octave
+
+ let a-s = accidental-string(note.letter, note.accidental, suppress-natural: true)
+
+ assert(side in all-notes-from-c, message: "unknown side: " + side)
+ assert(a-s in all-notes-from-c.at(side), message: "unable to calculate index of " + a-s + " above C")
+
+ let semitones-above-c = all-notes-from-c.at(side).position(s => s == a-s)
+
+ let index = middle-c-index + semitones-above-c + semitones-per-octave * octaves-above-middle-c
+
+ return index-note(index, side)
+
+}
+
+// convert from index-note type to letter-note type
+#let index-to-letter(note) = {
+ if note.at("type") == "letter-note" {
+ return note
+ }
+ assert(note.at("type") == "index-note", message: "unknown type for index-to-letter, type: " + str(type(note)))
+
+ let semitones-above-middle-c = note.index - middle-c-index
+ let octaves-above-middle-c = calc.div-euclid(semitones-above-middle-c, semitones-per-octave)
+ let semitones-above-c = calc.rem-euclid(semitones-above-middle-c, semitones-per-octave)
+ let octave = middle-c-octave + octaves-above-middle-c
+
+ let a-s = all-notes-from-c.at(note.side).at(semitones-above-c)
+ let letter = a-s.at(0)
+ let accidental = if a-s.len() == 1 {
+ none
+ } else {
+ a-s.at(2 - 1)
+ }
+
+ return letter-note(letter, octave, accidental: accidental)
+
+}
+
+// takes in a
+// e.g. for clef: "treble", note-letter: "C4", height is -1
+// e.g. for clef: "treble", note-letter: "F4", height is +0.5
+// e.g. for clef: "treble", note-letter: "B4", height is +2
+// Note that the integer changes at C, not A
+// e.e.g B4, C5, D5 are consecutive
+#let calc-note-height(clef, note) = {
+ assert(type(note) != type(""), message: "calc-note-height expects letter-note data type, not raw string")
+ assert(note.at("type") == "letter-note", message: "unsupported note type in calc-note-height")
+ // find difference in letters above/below C
+ // typst is zero-index based
+ let notes-above-c = all-letters-from-c.position(n => n == note.letter)
+
+ let mid-c-height = clef-data.at(clef).at("middle-c")
+
+ // now calculate height
+ mid-c-height + (note.octave - 4) * all-letters-from-c.len() * 0.5 + notes-above-c * 0.5
+}
+
+
+#let shift-octave(note, octaves) = {
+ if type(note) == str {
+ return shift-octave(parse-note-string(note), octaves)
+ } else if note.type == "index-note" {
+ return index-note(note.index + semitones-per-octave * octaves, note.side)
+ } else {
+ assert(note.type == "letter-note")
+ return letter-note(note.letter, note.octave + octaves, accidental: note.accidental)
+ }
+}
+
+// increments from one natural to the next
+#let increment-wholenote(note, steps: 1) = {
+ assert(note.type == "letter-note")
+ assert(note.accidental == none)
+
+ if steps == 0 {
+ return note
+ }else if steps < 0 {
+ panic("Decrementing notes not supported")
+ } else if steps >= 7 {
+ // skip a whole octave at a time
+ // for performance reasons
+ return increment-wholenote(shift-octave(note, 1), steps: (steps - num-letters-per-octave))
+ }
+
+ let old-letter-index = all-letters-from-c.position(x => x == note.letter)
+ let new-letter-index = calc.rem-euclid(old-letter-index + steps, all-letters-from-c.len())
+ let new-octave = if new-letter-index > old-letter-index {
+ note.octave
+ } else {
+ // we have wrapped around
+ note.octave + 1
+ }
+ let new-letter = all-letters-from-c.at(new-letter-index)
+
+ return letter-note(new-letter, new-octave)
+
+}
+
+
+// A -> B -> C ... G -> A
+#let increment-letter(letter) = {
+ let start-index = all-letters-from-c.position(x => x == letter)
+ let next-index = calc.rem-euclid(start-index + 1, all-letters-from-c.len())
+ return all-letters-from-c.at(next-index)
+}
+
+
+
+// takes in a letter-note
+// removes the sharp/flat/natural
+// replaces with none
+#let remove-accidental(note) = {
+ assert(note.type == "letter-note")
+ return letter-note(note.letter, note.octave)
+}
+
+#let add-semitones(start-letter, start-accidental, start-octave, steps: 1, side: "sharp") = {
+ assert(side in allowed-sides, message: "invalid side: " + side + ", must be one of " + allowed-sides.join(","))
+ if steps == 0 {
+ return (
+ letter: start-letter,
+ accidental: start-accidental,
+ octave: start-octave
+ )
+ } else if steps >= semitones-per-octave {
+ // skip a whole octave at a time
+ // for performance reasons
+ return add-semitones(start-letter, start-accidental, start-octave + 1, steps: steps - semitones-per-octave, side: side)
+ } else if steps < 0 {
+ panic("Decrementing by semitone not supported")
+ } else if start-letter == "B" {
+ // increment octave number when going from B to C
+ return add-semitones("C", none, start-octave + 1, steps: steps - 1, side: side)
+ } else if start-accidental == "b" {
+ // remove the flat
+ assert(side == "flat", message: "Cannot start with flat for sharp incrementing")
+ return add-semitones(start-letter, none, start-octave, steps: steps - 1, side: side)
+
+ } else if start-accidental in ("n", "", none) {
+ if start-letter in ("B", "E") {
+ // this note has no sharp, go to next white note
+ return add-semitones(increment-letter(start-letter), none, start-octave, steps: steps - 1, side: side)
+ } else if side == "sharp" {
+ // sharpen this note
+ return add-semitones(start-letter, "#", start-octave, steps: steps - 1, side: side)
+ } else { // flats
+ assert(side == "flat", message: "unknown side: " + side)
+ // flatten the next note
+ return add-semitones(increment-letter(start-letter), "b", start-octave, steps: steps - 1, side: side)
+ }
+ } else {
+ assert(start-accidental in ("s", "#"), message: "Unknown accidental: " + start-accidental)
+ assert(side == "sharp", message: "Cannot mix sharp notes and not sharp side")
+
+ return add-semitones(increment-letter(start-letter), none, start-octave, steps: steps - 1, side: side)
+ }
+}
diff --git a/packages/preview/staves/0.1.0/typst.toml b/packages/preview/staves/0.1.0/typst.toml
new file mode 100644
index 0000000000..4efa3c17ed
--- /dev/null
+++ b/packages/preview/staves/0.1.0/typst.toml
@@ -0,0 +1,29 @@
+[package]
+name = "staves"
+version = "0.1.0"
+entrypoint = "src/lib.typ"
+authors = [ "Matthew Davis" ]
+license = "MIT"
+description = "Draw musical clefs and key signatures."
+keywords = [
+ "Music",
+ "Clef",
+ "Treble",
+ "Bass",
+ "Alto",
+ "Tenor",
+ "Flat",
+ "Sharp",
+ "Key",
+ "Accidental",
+ "Score",
+ "Stave",
+ "Chord",
+ "Chromatic",
+ "Major",
+ "Minor"
+]
+categories = [ "components", "visualization", "fun" ]
+disciplines = [ "music" ]
+exclude = [ "/examples/*", "/docs/*.pdf", "src/test*" ]
+repository = "https://github.com/mdavis-xyz/staves-typst"
\ No newline at end of file