Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Two languages: translations, and letters or solfège

Files: crates/lineup-core/src/i18n.rs, crates/lineup-core/src/i18n/es.rs, crates/lineup-app/src/i18n.rs, crates/lineup-core/src/pitch.rs, crates/lineup-core/src/harmony/symbol.rs, crates/lineup-core/src/chord_sheet.rs, crates/lineup-core/src/file.rs and crates/lineup-app/src/app/settings.rs

Two settings change how Ferrous Lineup talks to you:

  • Edit > Settings… > Appearance > Language writes the menus, windows and settings in English or Español.
  • Edit > Settings… > Harmony > Note naming names notes, keys and chords with letters (C, F#, Bb major, Am7) or in solfège (Do, Fa#, Sib major, Lam7).

They are separate, so you can have Spanish menus with letters, or English menus with solfège. Until you choose a note naming, though, it follows the language: Spanish menus name notes in solfège, as Spanish schools do, and English menus with letters. Once you choose one, it stays, whatever language you pick after. Both settings change only what you see. A song saved with one choice opens exactly the same for someone with the other.

Notes named Do, Re, Mi

In English and German, notes are named with letters. In Spanish, Italian, French and Portuguese, and in the countries that speak them, they are named with syllables:

letters    C    D    E    F    G    A    B
solfège    Do   Re   Mi   Fa   Sol  La   Si

Sharps and flats are written after the syllable as after the letter: F# is Fa# (Fa sostenido in Spanish) and Bb is Sib (Si bemol). A key is named the same way, so B♭ major is Sib major, or Sib mayor with Spanish menus, and D Dorian is Re dórico.

This is fixed do: Do is always C, whatever the key. Some ear-training methods use a movable do instead, where do is the tonic of the key (in G major, do is G). The app always uses fixed do, as Spanish and Italian schools do, since a note’s name must not change with the key.

Octave numbers stay as they were: middle C is C4 in letters and Do4 in solfège, the octave starting at C and going up to B. Some French and Spanish books number octaves one lower and call middle C Do3. The app keeps one numbering, so a note keeps its number whichever way it is named.

Chords in solfège

A chord symbol is a root, a quality and sometimes a bass note. In solfège only the note names change:

letters    C    Am7     G7/B     Bdim     F#m7b5    Eb+
solfège    Do   Lam7    Sol7/Si  Sidim    Fa#m7b5   Mib+

Spanish-speaking musicians call the first way cifrado americano and the second cifrado latino. Lead sheets for jazz and pop are usually in letters, even in Spanish; teachers and songbooks often use syllables. With solfège chosen, the chord sheet, the harmony keys, the piano roll, the playlist’s key changes and the examples in the hints all write notes, keys and chords the second way.

Typing a chord either way

You can type a chord either way, whichever is chosen: Am7 and Lam7 are the same chord. After Enter, the bar shows it the way the setting names notes.

A few names could be read both ways. o is one way of typing a diminished chord (Bo is B°), so Do could be C (solfège) or D° (letters), and Do7 could be C7 or D°7. The app reads such a name the way the setting names notes: with solfège chosen, Do is C. Names that only make sense one way are read that way: Faug is F augmented even with solfège chosen, because “Fa” followed by “ug” is not a chord, and Fam is F minor even with letters chosen.

The quality is written the same in both: F augmented is Faaug in solfège. It reads back correctly, but Fa+ is easier on the eye, and the app reads it too.

On the chord sheet, typing on a selected bar starts typing in it, but L is the key that switches between pattern and song. With solfège chosen, La chords start with L, so there L starts typing instead. With letters chosen, no chord starts with L, and L switches as before.

What is saved

Projects, saved styles, the chord-sheet text that Ctrl+C copies, and playback traces are always written with letters, and in English. The settings never reach them. So a song you saved while seeing Lam7 contains Am7, and opens with Am7 or Lam7 according to the settings of whoever opens it.

In the code

Two settings, kept like the numerals setting

Language and NoteNaming (both in lineup-core) are kept like the minor-key numerals setting: an enum with a #[default], a thread_local! Cell that every function can read, and a with that sets it for a while and puts it back with a Drop guard, even after a panic.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum NoteNaming {
    #[default]
    Letters,
    Solfege,
}

thread_local! {
    static NOTE_NAMING: Cell<NoteNaming> = const { Cell::new(NoteNaming::Letters) };
}
}

A note is printed in hundreds of places, through its Display implementation ({} in format!). A per-thread value lets all of them follow the setting without passing it down through every function:

#![allow(unused)]
fn main() {
impl fmt::Display for NoteName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.format(NoteNaming::current()))
    }
}
}

The window sets both values from the saved settings before drawing each frame:

#![allow(unused)]
fn main() {
pub fn apply_settings(ctx: &egui::Context) {
    language(ctx).set();
    note_naming(ctx).set();
}
}

A video export draws its views on a thread of its own, which starts with the defaults, so it takes the language and the naming along, as it already took the numerals setting.

Solfège until you choose

egui keeps the window’s preferences as values under an Id, and get_persisted gives None for an Id nothing was stored under. That None is what tells “never chosen” apart from a choice, so the naming falls back on the language only then:

#![allow(unused)]
fn main() {
pub fn note_naming(ctx: &egui::Context) -> NoteNaming {
    ctx.data_mut(|data| data.get_persisted(note_naming_id()))
        .unwrap_or_else(|| language_naming(language(ctx)))
}

/// How notes are named where `language` is spoken.
fn language_naming(language: Language) -> NoteNaming {
    match language {
        Language::English => NoteNaming::Letters,
        Language::Spanish => NoteNaming::Solfege,
    }
}
}

Reading never stores anything; only set_note_naming, called when you click a naming in Settings, does. So until that click, every frame asks the language again, and switching to Spanish switches the notes to solfège. The match has no _ arm on purpose: a third language would not compile until someone says how it names notes.

Translating: the English text is the key

Each piece of text the interface shows is written in English in the code and goes through tr. It lives in lineup-core, not in the app, so that the texts the core writes, the names of styles, instruments, chords and scales, are translated too:

#![allow(unused)]
fn main() {
pub fn tr(text: &'static str) -> &'static str {
    match Language::current() {
        Language::English => text,
        Language::Spanish => in_spanish(text).unwrap_or_else(|| missed(text)),
    }
}
}

The Spanish catalog is in i18n/es.rs: a big match for the menus, the main windows and the settings, and, in the files of i18n/es/, tables of pairs by area (the app, theory, instruments, the library, the songs), gathered once into a HashMap:

#![allow(unused)]
fn main() {
static TEXTS: LazyLock<HashMap<&'static str, &'static str>> =
    LazyLock::new(|| TABLES.into_iter().flatten().copied().collect());

/// `text` in Spanish, or `None` for a text with no translation yet.
pub fn translate(text: &str) -> Option<&'static str> {
    main_texts(text).or_else(|| TEXTS.get(text).copied())
}
}

LazyLock builds the map the first time it is used, on whichever thread asks first, and every thread shares it after that. flatten turns the five tables into one run of pairs, and collect builds the map from them.

Using the English text itself as the key has two advantages. The code still reads naturally (tr("Settings"), not tr(SETTINGS_TITLE)), and a text with no translation yet simply stays in English, through unwrap_or_else. The cost is that changing an English text means changing its key in the catalog too, or the Spanish one is lost; a test catches that (see below).

Both the argument and the result are &'static str: text written in the program itself, which lives as long as the program does. So tr can return either one without copying anything.

A few relatives of tr cover the other cases:

  • trf("{} steps a bar", &[&12]) puts its arguments where the translated text has {}, so the Spanish can put the number among other words: “12 pasos por compás”.
  • trc("clef", "Bass") is for a word whose translation depends on where it is shown: the bass clef is “Fa”, the instrument “Bajo”. The catalog has it as "clef|Bass".
  • tr_name translates the names the app gives things, a channel, a track or a pattern: “Bass” is “Bajo”, “Pattern 12” is “Patrón 12” and “A drums” is “Batería de A”. A name you typed yourself stays as typed.
  • A catalog text can name notes in braces, as in "{Bb} mayor"; they are named as the setting says (see Examples that follow the setting). Such a text is built once for each naming and kept, with Box::leak, which turns a String into a &'static str that is never freed. There are only so many texts and two namings, so the memory kept has a limit.

Names on screen, letters in files

Since Display follows the setting, anything that turns a note into text and saves it would save solfège. The places where data leaves the program run with letters instead, through with:

#![allow(unused)]
fn main() {
pub fn to_text(project: &Project) -> Result<String, FileError> {
    // …
    let file = Language::English.with(|| NoteNaming::Letters.with(|| ProjectFile::from(project)));
    let mut text = NoteNaming::Letters.with(|| ron::ser::to_string_pretty(&file, style))?;
    // …
}
}

A chord marker is saved as its text, through the serde attributes into = "String" and try_from = "String" (see Twelve-tone rows). Its conversions run with letters too, so a marker is saved as Am7 wherever it is saved from:

#![allow(unused)]
fn main() {
impl From<HarmonyName> for String {
    fn from(harmony: HarmonyName) -> Self {
        NoteNaming::Letters.with(|| harmony.to_string())
    }
}
}

Saved styles, the chord-sheet text and the playback trace do the same. The library’s own charts are written in letters in the source, so they are read with letters too.

Reading a name either way

NoteName::read_as reads a note at the start of a text in one naming, and gives back the name and the rest of the text. For solfège it tries each syllable:

#![allow(unused)]
fn main() {
NoteNaming::Solfege => Letter::ALL.into_iter().find_map(|letter| {
    text.strip_prefix(letter.solfege())
        .map(|rest| (letter, rest))
})?,
}

strip_prefix returns the text after the prefix if the text starts with it, and None if not. find_map returns the first Some. “Sol” and “Si” both start with S, but neither is the start of the other, so only one can match.

read_either tries both namings, the setting’s own first, and keeps the first reading whose rest fits:

#![allow(unused)]
fn main() {
pub fn read_either<'a>(
    text: &'a str,
    fits: impl Fn(&'a str) -> bool,
) -> Option<(NoteName, &'a str)> {
    NoteNaming::current()
        .reading_order()
        .into_iter()
        .filter_map(|naming| NoteName::read_as(text, naming))
        .find(|&(_, rest)| fits(rest))
}
}

What “fits” means depends on the caller, so it is a closure. A chord symbol fits when the rest is a quality and maybe a bass; a whole note name fits when nothing is left:

#![allow(unused)]
fn main() {
pub fn parse(text: &str) -> Option<NoteName> {
    NoteName::read_either(text, str::is_empty).map(|(name, _)| name)
}
}

str::is_empty is a function, and a function can be passed wherever a closure with the same arguments is expected.

The lifetime 'a says that the rest handed to fits, and returned, is a piece of text, not a copy. That is how “Faug” works with solfège chosen: “Fa” leaves “ug”, which fits rejects, so the letters reading, F with “aug”, is tried and kept.

Examples that follow the setting

The hints on the chord sheet and in the settings give example chords. They are written with the notes in braces, {A}m7 {D}7, and with_notes names each one as the setting says, so with solfège chosen the hint reads “Lam7 Re7”. Braces around anything that is not a note name, like the set {0,1,4} in the same hint, are left alone.

L for La

ChordSheet::takes_l says whether an L typed now belongs to the sheet: the sheet has the keyboard, a bar is selected, nothing is being typed yet, and notes are named in solfège. The app’s L shortcut checks it first:

#![allow(unused)]
fn main() {
let l_types = self.chord_sheet.takes_l() && !self.live.typing();
if !typing && !controls_active && !l_types && pressed(egui::Key::L) {
}

!self.live.typing() is there because with the typing keyboard on, letters play notes, and L switches pattern and song as usual.

Outside the app

The Linux desktop file has Spanish lines next to the English ones, GenericName[es]=Banda de acompañamiento for example, which a Spanish desktop shows in its menus. The AppStream file that software centres read gives each paragraph of the description a Spanish twin, <p xml:lang="es">, and appstreamcli validate checks it.

The tests

every_window_writes_in_spanish opens six demos in Spanish with every window open, then the shortcuts, About, the video export, the style editor, each page of Settings and each menu. It fails if anything asked tr for a text the catalog has no Spanish for, or if any text drawn on screen is an English text the catalog does have Spanish for, which catches text drawn without tr. In the core, every_name_and_description_the_app_shows_has_spanish does the same for every instrument, style, chord, scale and demo name the core gives the app.

solfege_changes_how_names_look_not_what_is_saved builds every demo song twice, once with letters and once with solfège, and checks that the saved file and the chord-sheet text are identical, and that every chord, written as the sheet shows it in solfège, reads back as the same chord.

names_are_read_in_letters_or_solfege reads Sib7, Fa#m, Sol and Reb, and checks that Do7 starts with D when letters are chosen and with C when solfège is. solfege_is_a_way_of_showing_names checks that Am7 shows as Lam7, G/B as Sol/Si and B♭ major as Sib mayor, and that Lam7 and Am7 are the same chord.

in_solfege_l_types_la_chords_and_in_letters_it_switches_mode selects a bar on the chord sheet, presses L with solfège chosen and types Lam7, then checks that the mode stayed and the bar holds A minor 7; with letters, L switches the mode and leaves the bar alone.

spanish_names_notes_in_solfege_until_a_naming_is_chosen switches the language back and forth with no naming chosen, and the naming follows it; then it chooses a naming and switches the language again, and the naming stays. spanish_names_notes_in_solfege_and_keeps_letters_once_chosen does it the way you would: Spanish menus, Armonía, Letras (C, D, E…), and a restart of the app, after which the notes are still letters.