Choir LMS is an Nx-based monorepo for building a suite of tools that help choirs organize learning materials and make rehearsal preparation more effective.
The long-term goal is a broader choir learning platform that can bring together storage integrations, rehearsal materials, collaboration workflows, and role-specific tools for singers, section leaders, and directors. The first product slice is narrower by design: a mobile rehearsal player that turns shared audio into repeatable personal practice sessions.
Choirs often already have the raw material they need for self-rehearsal, but it is scattered across shared drives, folders, and messages. This project aims to add a rehearsal-focused layer on top of those existing assets so choir members can spend less time searching and more time practicing.
At a high level, the platform is intended to support:
- integration with existing storage systems, starting with Google Drive
- audio, document, schedule, and rehearsal material access from a unified product surface
- practice-specific workflows such as saved loops, playlists, annotations, and guided rehearsal content
- future expansion into broader choir operations and learning workflows
The current focus is a mobile-first rehearsal player built with React and intended for native-feeling mobile playback.
For the MVP, the player is expected to support:
- browsing and playing audio files from Google Drive
- marking start and end points on a track and saving them as named loops
- adding full tracks and saved loops into playlists
- ordered playback, repeat, and shuffle for rehearsal sessions
- lyrics saved with a track and read during playback (see Lyrics)
- optional sheet music links per track, opened in a full-screen viewer (see Sheet music)
- named markers at points of a track, shown as flags on the waveform, that jump playback and turn into loops (see Markers)
- a piano for checking pitches against the track and the score (see Piano)
- pitch detection that shows the note you sing, in tune or not, against the piano and the score (see Pitch detection)
- native mobile transport integration where practical, including background playback and lock-screen controls
The MVP explicitly does not include offline playback, document collaboration, score annotation, recording overlays, or broader rehearsal management workflows.
A saved track can hold one body of plain-text lyrics, stored with the track in the on-device library like its tags. Loops and adjusted tracks do not keep their own copy: they show the lyrics of the track they came from, and removing a track removes its lyrics.
- Adding lyrics: open
Lyrics(orEdit lyrics) from any track, loop, or playback menu. Type or paste text, useSearch <source>(Google by default) to open a search for the title and artist in the system browser, with a dropdown for the other sources inSettings, or useLook upto fetch suggestions. - Where lookup comes from:
Look upasks the open LRCLIB service. It runs only when you tap it, after a one-time notice on the device, and sends the title, artist, and track length you see in the editor, nothing else. Results are suggestions: you review and can edit them, and nothing is saved until you pressSave. Timed (synced) lyrics are ignored. - Privacy and copyright: saved lyrics stay on the device; the app never uploads or shares them. Looked-up text is third-party content that you choose to keep in your own library, and the lyrics service's own terms apply to the lookup.
- Reading: the playback screen has a Lyrics section, the mini-player shows a
Lyricscontrol while the playing item has lyrics (a half-height peek), andView/Expandopens a full-screen reading mode. One text-size setting (SmalltoExtra large) applies to all three.
A saved track can carry up to 10 sheet music sources, such as the full score and one part. The app stores only a link (a Google Drive file or a web address), a label, and the chosen parts; it never copies, hosts, or uploads a score. Loops and adjusted tracks show the sources of the track they came from, and removing a track removes its links.
- Reaching it:
Sheet musicin any track, loop, or playback menu (afterLyrics) opens a manager sheet. There is no sheet music section on the playback screen.Viewopens a full-screen viewer with a play/pause control, and the screen stays awake. - Adding:
From Google Drive(a picker that shows only PDF, MusicXML, MIDI, and MuseScore files),From a web link(pasted, detected, validated), orSearch <source>(MuseScore by default; opens a prepared search in the system browser, with a dropdown for IMSLP, a web PDF search, and any source you add). The sources are two ordered lists inSettings,Score searchandLyrics search: reorder them (the first is the default), remove all but one, add a built-in back, or add your ownhttpsaddress with{query},{title}, or{artist}where the search goes. The app only opens these addresses in the system browser; it never fetches from them. - Formats: PDF is drawn as pages on the web build, in one of four layouts chosen with the viewer's
Layoutbutton:Scroll(a column of pages fitted to the width, the default),Pages(one whole page at a time, turned by swiping, the arrow keys,Page Up/Page Down, the wheel, orPrevious pageandNext pagebeside the page indicator, snapping to each page, with no sideways scrollbar; the keys and buttons slide briefly, instantly if the device asks for reduced motion),Strip(every page side by side in one row that scrolls sideways, each as tall as the viewer), orSpread(two pages side by side, page 1 with page 2, turned a spread at a time and snapping likePages; offered only while the viewer is at least 760 px wide, so a landscape tablet or desktop window, and shown asPageson the same page when the window narrows, without losing the choice).Page 3 of 81shows in every layout (a wideStripor aSpread, where several pages show side by side, readsPages 3-5 of 81; on a phone held sideways it moves into the side rail as3/81betweenPrevious pageandNext page, and the rail scrolls when the controls do not fit) and is a button: it opensGo to page, a sheet with a page-number field (andGo) above a grid of page thumbnails, drawn a few at a time as you scroll, and choosing a number or a thumbnail moves the viewer to that page. Changing layout, zooming, or turning the phone keeps the page you are on, and a new layout starts at its fit (zoom returns to 100%). After a sheet closes or a toolbar button is used, the arrow keys still turn pages. The layout you choose is remembered for the device; turn onOnly for this filein the sheet to give one file its own layout (saved on that source, andUse the device layoutremoves it). Zoom runs from 50% to 400% in every layout (100% fits the width inScroll, the whole page inPages, the height inStrip, and the whole pair inSpread) by the buttons, a pinch,Ctrl/Cmd+ wheel, or a double tap, and keeps the point under your fingers in place; a zoomedPagesorSpreadview pans freely and snaps again at 100%. Native iOS and Android do not draw PDFs yet. MusicXML (.musicxml,.xml,.mxl) is drawn as staves in one strip that scrolls sideways, shrunk to fit the height you have (never below 50%) until you zoom by hand;Fit to heightbrings that back. MIDI is converted to MusicXML on the device and drawn the same way; part names are read from the file (including exporters that name a track in the empty track before it), a pickup is found from where the long notes land, time and key signature changes are followed bar by bar, a drum or beatbox track is drawn on a percussion staff and counts as a vocal part, runs of 32nd notes are recognized, and a short gap between notes is kept as a rest rather than closed, but the notation is approximate, performed (not score-exported) MIDI will look rough. Every MIDI file is drawn as it is, whatever it holds (a Performous game file shows as stacked chords); the app does not judge whether a file is useful. MuseScore files (.mscz,.mscx) andmusescore.compages are not rendered: they open outside the app, so export MusicXML from MuseScore to view a score here. The app never fetches from musescore.com. Finding your place:Go toin the viewer's controls opens a drawer over the left of the score with a bar-number field (the number printed on the score), the file's rehearsal marks as a tap-to-jump list, and the part names in staff order; it takes no room while you read. Tapping a staff shows its part's name for a moment. A long score (over 96 bars) is drawn in runs of bars as you scroll, so any length works and a short arrangement is drawn in one piece. - Parts: for MusicXML and MIDI the viewer shows the vocal parts by default (a part is hidden only when it is known to be instrumental).
Vocal parts,All parts, and per-part chips change the choice, and it is remembered per source. A PDF has no parts. - Your parts: Settings has an ordered
Your partslist (Soprano, Mezzo-soprano, Alto, Tenor, Baritone, Bass, Beatbox, or any name you type, such asLeadorAlto 2), reordered by dragging or with the arrows. A source with no saved choice of its own opens on the first listed part the score has: every name is tried for an exact match first (digits count, soAlto 2finds onlyAlto 2), then for a close match (Altoalso findsAlto 1andAlto 2; instrumental parts are never suggested), and only that part's staves show. With nothing listed, or nothing matching, it follows the exact part you last showed alone with the chips, then falls back to the vocal parts.Make <Part> my partin the viewer puts the exact part showing at the top of the list, and pressing it again removes it. A part saved on a source always wins. The list is stored on the device, not per track. - What is fetched, and when: a file is downloaded only when you open it, with your existing Google authorization for Drive files, or directly from the address for a web link. On the web build, a host that does not allow browser requests shows
Open in browser; saving the file to Drive always works. The renderers (OpenSheetMusicDisplay, pdf.js, a MIDI parser) are separate chunks loaded when a viewer opens, not at startup. Nothing is uploaded. - Not yet: the iOS and Android renderers are tracked in
native-playback-follow-ups; until then a native build lists the sources and opens them outside the app.
A saved track can hold up to 200 named markers, such as Verse 2 or Bridge, each at a point in the audio. They are stored with the track like its tags and lyrics. Loops and adjusted tracks show and edit the markers of the track they came from (positions are in the original audio's time, so speed and pitch shaping never move them), and removing a track removes its markers. Markers are not library entries: they do not appear in Files, search, tags, or playlists.
- Reaching them:
Markersin any track, loop, or playback menu (afterSheet music) opens a list for the owning track; opened from a loop it shows only the markers inside that loop. The playback menu also hasAdd marker here. - Adding:
Add marker heretakes the playhead, or long-press the playback waveform to add at the pressed point. A new marker is namedMarker N; a confirmation offersRename. Each row's menu hasRename,Move(nudges,Set to current position, or a typedmm:ss.s), andDeletewith undo. - Loop boundaries: every saved loop shows
<name> startand<name> endrows, computed from the loop, so they follow edits and disappear with the loop. They are read-only;Keep as markercopies one into your own markers.My markershides them (the choice is remembered on the device and also filters the waveform flags). - Jumping: tap a row, or a flag on the waveform, to seek there while playing or paused; from the library it loads the track and starts at the marker. A marker past the end of a replaced, shorter audio file is greyed and not jumpable but is kept.
- Flags: the playback waveform draws your markers as accent hairlines with a small head and loop boundaries as shorter dim ticks; press one to see its name, long-press it for its row actions.
- Markers to loops:
Loop to next markeron a row,Selecttwo markers thenMake loop, orCreate loops from markers(one loop per section between your markers, named after the start marker, with an optionalIntro). All open or use the normal loop editor and save operation. - In the loop editor: your markers (never loop starts or ends) are drawn on the editor waveform (
Hide markers/Show markerstoggles them); tap the flag button on the Start or End card to choose that edge from the list, or tap the card and then a flag. Markers the armed edge cannot take (a start at or after the end, or an end at or before the start) are dimmer and listed separately; choosing one asks whether to move the other edge to the track start or end, pick another marker, or cancel. Markers are read-only there.
A singer checking a pitch can open an 88-key piano (A0 to C8) over the app without leaving the track or the score. It is a web-only capability for now: native has no piano sound, so its controls are absent there (tracked in native-playback-follow-ups).
- Opening it: the
Practice toolsmenu (the tab bar item, the playback screen header, and the sheet music viewer's controls all open the same menu; it needs no track, so it works before anything has played and after playback ends), It docks above the mini-player and tab bar (under the score in the viewer, where the score area shrinks to fit), never pauses, ducks, or moves the track, and remembers whether it is open, its height, and where it was scrolled. Drag its handle to one of three heights (a short landscape window caps it at the smallest);Close pianocloses it everywhere. - Playing: keys sound while held, with several fingers at once, and sliding across keys plays a glissando. A touch that begins on a key plays and never scrolls; the strip beneath the keys, a labelled map of the octaves (C1 to C8), scrolls the keyboard. Notes are concert pitch (A4 = 440 Hz) and do not follow the track's pitch shaping. It opens on middle C, or on the range of the part shown in the sheet music viewer. C keys carry their octave (
C4). - Computer keyboard: with the piano open on a desktop browser, the
A S D F G H J K L ;row plays the white keys from middle C and theW E T Y U O Prow the black keys;ZandXmove an octave down or up. Shortcuts and typing in text fields are left alone. - Tapping the score: tapping a notehead in MusicXML or MIDI sheet music sounds its pitch (at least 350 ms) and lights the key; a part's transposition is applied, rests and tied continuations do nothing, and a PDF is not tappable.
- Settings: a list of categories (
Bulk selections,Piano,Pitch detection,My parts,Search sources, andOffline storagewhere audio can be stored), each opening its own page; back or Escape returns to the list. ThePianopage chooses the sound (Pianois a synthesized tone for now; recorded samples are planned,Organis a simple oscillator), whether every key is named, and how black keys are spelled (sharps or flats). The piano's volume is in its handle row and is separate from the track's.
A singer can turn on the microphone and see the note they are singing, how far it is from the nearest note in cents, and whether that is precise, near, a quarter tone off, or off pitch (always stated in words as well as colour). Like the piano it is web-only for now: native has no microphone capture, so its controls are absent there (tracked in native-playback-follow-ups).
- Turning it on:
Pitch detectionin thePractice toolsmenu. The firstListenexplains why the microphone is needed before the browser asks. Nothing you sing is recorded, stored, or sent anywhere; it assumes headphones, because sound from speakers reaches the microphone and is detected like singing. - Monitor strip: the note, a cents gauge, and a short history, docked above the piano or the mini-player. It remembers whether it is on, and shows idle, blocked, paused (while the app is in the background), and interrupted states, recovering by itself when the microphone returns.
- Pitch view: the strip's expand button opens a full-screen view with a large readout, a focus panel tracing closeness to a target (the nearest note, or one you hold), and a progression roll you can zoom from about 5 to 60 seconds. It has a landscape layout and does not touch playback.
- Minimum volume: sounds quieter than a cutoff (-60 to -20 dB, default -40) are ignored. Set it from the Pitch view's settings sheet or the
Pitch detectionpage in Settings, over a live level meter, so it sits just above the room noise. - On the piano and the score: the sung key lights up (with an edge pill pointing to it when it is off screen), and in MusicXML or MIDI sheet music the matching noteheads are marked. A transposing part is handled, and while the playing track is shifted in pitch the score follows the shifted note and says
Following track pitch ±N st.Show other-octave matches(a toggle in the sheet music viewer's toolbar, shown only beside a score) also marks the same note in other octaves. A PDF is never marked.
This repository is intentionally scaffolded as an Nx workspace rather than a single app so the system can grow into a library of apps and shared packages over time.
The expected shape is:
- one or more user-facing applications under
packages/ - shared domain and integration packages for media, storage, auth, and choir-specific workflows
- OpenSpec-driven change proposals to keep product scope and implementation aligned as the platform expands
The first change proposal for this direction is add-mobile-rehearsal-player under openspec/changes/.
packages/is the workspace home for future apps and shared librariespackages/pitch-detectionholds the framework-agnostic pitch-detection pipeline (estimator, gates, smoother, note segmenter); it is imported statically, unlike the lazily loadedmidi-notationpackages/audio-mixingholds the framework-agnostic recording sync logic (clock-anchor fit, timeline builder, balance gains, sync controller); the webTransportandVoiceimplementations live in the app- Pitch detection third-party licences (both permissive, checked against the installed packages):
pitchy4.1.0 (MIT, Copyright 2018-2021 Ian Johnson; its README's licence section is copied from another project, theLICENSEfile is the authority) and its dependencyfft.js4.0.4 (MIT, Copyright Fedor Indutny 2017) openspec/contains proposals, designs, specs, and tasks for planned worknx.jsonand the root TypeScript configuration provide workspace-level build and project settings- Cross-platform playback architecture details are documented in
docs/mobile-cross-platform-audio-playback.md
This workspace uses npm workspaces with the committed root package-lock.json.
Prefer npm exec -- <tool> for local CLIs, especially npm exec -- nx ... for
workspace tasks.
Useful Nx commands:
npm exec -- nx show projects
npm exec -- nx graph
npm exec -- nx syncThe currently implemented automated tests run through Nx test targets and execute with Node's built-in test runner using tsx --test.
- Test sources use
node:testplusnode:assert/strict. Mobile component specs render with@testing-library/react-native/purethrough a smallsrc/test-support/preload that mapsreact-nativetoreact-native-web. - Primary command pattern:
npm exec -- nx run <project>:test. - In this workspace, Jest and Vitest are not the active test harness for existing package tests.
- For targeted mobile test runs, use:
npm exec -- nx run mobile-rehearsal-player:test-file -- --file=src/path/to/file.spec.tsnpm exec -- nx run mobile-rehearsal-player:test-pattern -- --pattern='src/**/*.spec.ts'
The mobile rehearsal player reads Google Drive OAuth IDs from packages/mobile-rehearsal-player/.env.
For the default app identifiers in this repo:
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_IDshould be an iOS OAuth client for bundle IDcom.choirlms.mobileEXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_IDshould be an Android OAuth client for packagecom.choirlms.mobileEXPO_PUBLIC_GOOGLE_WEB_CLIENT_IDis only needed when running the app on web
The app asks Google for two Drive scopes: https://www.googleapis.com/auth/drive.readonly (browsing and playing files) and https://www.googleapis.com/auth/drive.file (saving a sheet music file to the user's Drive, the Save to Drive flow). The OAuth consent screen in the Google Cloud project must list both, and every account that signs in while the app is in Testing must be a test user. The console steps are in design.md; no new client ID or .env value is needed.
To get the Android debug SHA-1 for local expo run:android builds:
keytool -list -v -alias androiddebugkey -keystore ~/.android/debug.keystore -storepass android -keypass android | grep SHA1If ~/.android/debug.keystore does not exist yet, generate the standard Android debug keystore first:
keytool -genkeypair -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"After filling the client ID for the platform you are testing, verify the Expo config and launch a dev build:
cd packages/mobile-rehearsal-player
npm exec -- expo config --type publicnpm exec -- nx run mobile-rehearsal-player:run-ios
# or
npm exec -- nx run mobile-rehearsal-player:run-androidFor deterministic local and CI validation, npm exec -- nx run mobile-rehearsal-player:build performs a local Expo export. Use npm exec -- nx run mobile-rehearsal-player:eas-build when you intentionally want to trigger an EAS build.
The repository includes .github/workflows/deploy-mobile-web-pages.yml to export and deploy the mobile web app to GitHub Pages automatically after the CI workflow succeeds for main, and it can also be run manually with a ref input through workflow_dispatch.
Before the workflow can publish, enable GitHub Pages for the repository and set the build source to GitHub Actions in the repository settings.
The workflow reads the configured Pages base path from GitHub and passes it to Expo as EXPO_BASE_URL, so project sites such as https://vehpus.github.io/choir-lms/ and custom domains export the correct asset URLs.
For Google Drive sign-in to work in the deployed web build, add repository variables for EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID, EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, and EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID. The workflow also reads optional repository variables for EXPO_PUBLIC_APP_SCHEME, EXPO_PUBLIC_IOS_BUNDLE_IDENTIFIER, EXPO_PUBLIC_ANDROID_PACKAGE, EXPO_PUBLIC_GOOGLE_DRIVE_SUPPORTED_MIME_TYPES, and EXPO_PUBLIC_GOOGLE_DRIVE_SUPPORTED_EXTENSIONS.
Do not add GOOGLE_WEB_CLIENT_SECRET to the repository or workflow for this frontend-only deployment.
To preview the same path handling locally for the current repository URL shape:
EXPO_BASE_URL=/choir-lms npm exec -- nx run mobile-rehearsal-player:buildOpenSpec workflow:
openspec list --json
openspec status --change "add-mobile-rehearsal-player"- repo-level policy lives in
AGENTS.md - OpenSpec change artifacts auto-load additional guidance from
.github/instructions/openspec-deliberate-execution.instructions.md openspec-checkpointed-implementationruns the one-subtask execution loopimplementation-checkpointhandles the validated pause for feedback, manual verification, and commit decisions
The repo carries workspace-local GitHub Copilot skills under .github/skills/
with mirrored agent copies under .agents/skills/ for repeated mobile design
and implementation work:
mobile-folder-structurefor Expo-inspired naming and folder-organization work inpackages/mobile-rehearsal-player, especially when reducing flat directories or grouping files by feature ownershipexpo-liquid-glassfor guarded glass or translucent UI work in the mobile shell, mini-player, playback sheet, and modal surfacesdesign-auditfor phased UI and UX audits of the mobile rehearsal player before broader visual refactorsapple-hig-iosandmusic-ui-iconographyfor iOS pattern decisions and playback icon semantics
The repository is still in early setup. The product direction and first implementation slice are defined in OpenSpec, and the next step is implementation of the mobile rehearsal player foundations.