An open-source multitrack backing-track player for live musicians - a phone replacing the laptop + audio interface + DAW rig (a "VS"/Playback-style setup). Load a project of audio stems and it plays them all sample-locked, with per-stem volume/mute/solo/bus routing and a click. Everything is local; there is no backend.
Built with Expo (SDK 57) and
react-native-audio-api,
Software Mansion's native Web Audio API implementation for React Native.
VS stands for Virtual Sound: the multitrack backing tracks a band plays alongside live. It fills in the parts nobody on stage is playing - synths, pads, backing vocals - so a small band can sound like its studio recordings. The click is what holds it together: the band plays to the metronome so it stays locked to whatever the VS is playing.
This app is a VS. Not a companion to one, not a way to prepare tracks for one
- the thing itself, running on a phone instead of the usual laptop + audio interface + DAW rig.
The name repeats itself: "Virtual VS" unpacks to "Virtual Virtual Sound". That's known. VS stopped being read as an acronym a long time ago and works as a term on its own, the way people say "ATM machine" or "PIN number".
engine/ One shared AudioContext + transport + per-track/bus node graph
store/ Redux Toolkit - committed mixer state, library, setlists, settings
storage/ Loads a project's manifest.json and decodes its stems
ui/ Library and Project screens (play + edit + create)
setlist/ Stub - multi-song controller, preload-next, pad crossfade (TODO)
control/ Stub - BLE-MIDI footswitch (TODO)
The whole app shares one AudioContext - one sample clock - held by a
single AudioEngine instance (engine/index.ts's audioEngine). Sync
across stems comes from scheduling every track's AudioBufferSourceNode to
start() at the same future context.currentTime (a small lookahead),
not from any per-track timer.
Signal graph per stem:
BufferSource -> trackGain -> {cue and/or main bus gain} -> bus panner -> destination
There are two buses, cue and main; a track routes to one, the other, or
both. This library's Web Audio surface has no ChannelMergerNode, so the
hard cue(L)/main(R) split for a TRS Y-split cable is done with a
StereoPannerNode per bus instead (pan = -1 / pan = +1) - "monitor"
mode centers both panners (pan = 0) so cue and main sum to both output
channels for rehearsing on normal headphones.
- Volume = the per-track
trackGainnode's gain. - Mute = ramp that gain to 0; unmuting just re-reads the still-remembered committed volume, so there's no separate "last volume" bookkeeping.
- Solo = every non-soloed track is treated as effectively muted.
- Click: if a project has no click stem, one is rendered once as a full
project-length
AudioBufferfrombpm(engine/clickTrack.ts) and played back through the exact same scheduling path as any other stem, routed cue-only - so it's sample-locked by construction, with no separate per-beat scheduling logic to keep in sync. - Transport: Web Audio buffer sources are one-shot and can't be
restarted, so pause/resume works by stopping every source and recreating +
rescheduling them at the correct offset. The playhead is computed as
context.currentTime - scheduledAtContextTime + playheadOffsetSecand is never stored in Redux - seehooks/usePlayhead.ts, which polls the engine viarequestAnimationFrameinstead. Reaching the end of the longest stem is detected via that source's nativeonEndedevent, not by polling the playhead against a duration in a React effect.
Redux Toolkit slices, normalized with createEntityAdapter where the data
is a collection (projects, setlists, committed per-track mixer state, pedal
mappings). There's no backend, so no RTK Query.
What's in the store: the project library, setlists, app settings, pedal
mappings, and each track's committed volume/mute/solo/bus routing
(tracksSlice.ts, keyed by ${projectId}:${trackId}).
What's not in the store: the live playhead (see above) and in-progress
fader drags. ui/components/VerticalFader.tsx calls the engine directly on every
touch move and only dispatches to the store once, on release - dispatching
a fader's value on every frame would cause a re-render storm and bloat
devtools.
ProjectSource is a small abstraction over "where a project's manifest and
stems came from". Every project is one the user built (see
storage/importProject.ts), resolving its stems to file:// URIs via
expo-file-system's v57 File/Directory API; the abstraction is what would
let a project resolve them from somewhere else without the decoder knowing.
storage/projectLoader.ts's decodeProjectAudio() runs every stem through
the engine's AudioContext.decodeAudioData() once, up front.
// setlist.json
{
"name": "…",
"songs": ["projectId", "…"],
"advance": "manual|auto",
"padBetween": true,
}See src/types/project.ts and src/types/setlist.ts.
setlist/- the multi-song controller, preload-next, and pad crossfade described in the spec aren't implemented. The data model and Redux slice already exist so the rest of the app has something stable to build against. Seesrc/setlist/README.mdfor the concrete TODOs.control/- BLE-MIDI footswitch support.react-native-ble-plxis intentionally not installed yet;src/control/README.mdhas the exact install + Expo config-plugin steps, including the BLE-MIDI GATT service/characteristic UUIDs, so adding it later is a known quantity rather than a research project.- Automatic cloud backup - backing up is a deliberate tap today (see Backup and sharing below). Uploading on its own, in the background, would need a Google/Microsoft OAuth client per build and token refresh; the share-sheet route deliberately avoids both.
A project or a whole folder can be packed into a single .vvs bundle -
manifests, the mix, and every stem - and handed to the OS share sheet. That is
how a set reaches Google Drive: Drive, OneDrive, Dropbox, AirDrop and a USB
cable are all just targets in the same sheet, so the app never needs a Google
account, an API key, or an OAuth client of its own. The user's own Drive app
owns the upload.
Coming back the other way needs nothing extra either. A bundle someone shares from their Drive arrives through the ordinary file picker: ⋯ → Import a backup…, pick the file, and its projects and the folder that grouped them land in the library.
- Folder → ⋯ → Export… packs that folder and its songs.
- Project → mixer drawer → Export… packs one project.
- A project whose id is already in the library is left alone, so re-importing your own backup changes nothing and a shared set can't overwrite your mixes.
- A folder that already exists is merged - existing order kept, new songs appended - so someone can send you an updated set without wiping the songs you added to your copy.
The container is documented at the top of storage/bundleFormat.ts. It is
deliberately not a zip: that would mean a native archive dependency or
compressing hundreds of megabytes on the JS thread, for almost no size win on
audio that is already compressed. Both ends stream in chunks, so a bundle is
never held in memory.
Exporting and importing are blocked while the transport is playing, like every other path that rebuilds files under a live set.
This needs an Expo development build - react-native-audio-api is a
native module, so Expo Go will not work. npx expo start on its own is
also not enough; build a dev client at least once per platform:
npm install
# iOS (needs Xcode + a simulator or a device)
npx expo run:ios
# Android (needs Android Studio + an emulator or a device)
npx expo run:androidAfter the first native build, npx expo start (or the ios/android npm
scripts) reconnects to the same dev client for fast JS-only reloads - you
only need to re-run expo run:* when native config (e.g. app.json
plugins) or a native dependency changes.
The Library starts empty. Tap + New, then add stems from the file picker
to build a project - two or more files that are meant to line up, so you can
hear that they stay in sync. Set a tempo if you want the generated click
(audible if you route a track to cue and are on a split/monitor setup).
Then try dragging a fader, muting/soloing a track, changing its bus routing,
and toggling monitor/split. + Folder groups projects together.
Tagged releases publish a signed APK to the
Releases page. Download the
.apk and open it on your Android device.
You should not have to trust a stranger's APK. Every release carries proof of where it came from, and all three checks below are things you run yourself.
1. Confirm GitHub built it, from this repo, at that commit:
gh attestation verify virtual-vs-<version>.apk --repo campos20/virtual-vsEach build records a signed provenance attestation in a public transparency log, tying the APK to the workflow run and commit that produced it. If someone modified the APK or built it somewhere else, this fails. This is the check that matters most - it covers the other two.
2. Confirm the file wasn't altered after upload:
sha256sum -c SHA256SUMS.txt3. Confirm it was signed with the project's release key:
apksigner verify --print-certs virtual-vs-<version>.apkEach release publishes the signing certificate's SHA-256 in its notes. Step 1 is the check that actually ties the APK to its source commit; this one additionally confirms releases share a signing identity, which is also what lets Android upgrade an install in place.
Releases are cut by pushing a tag - see docs/RELEASING.md.
There's no EAS Build config in this project (no eas.json) - releases are
built locally with the same native Android toolchain expo run:android uses
under the hood.
npx expo run:android --variant releaseThis prebuilds android/ if it doesn't already exist (see Running the
app - that folder is gitignored and regenerated on
demand, not committed), then builds and installs onto whatever
device/emulator you pick. The APK lands at
android/app/build/outputs/apk/release/app-release.apk either way - grab it
from there to hand out separately, or skip the install step entirely with
cd android && ./gradlew assembleRelease.
A local release build like this signs with Expo's stock debug keystore -
fine for installing on your own devices, but not something to hand out.
Published releases are different: CI signs them with the project's real
release key via
plugins/withAndroidSigning.js, which only
activates when the signing properties are passed in. See
docs/RELEASING.md.
Same story - no EAS, no App Store Connect config here, just the local Xcode
toolchain expo run:ios drives:
npx expo run:ios --configuration Release --device--device on its own prompts you to pick a connected/paired device; pass a
name to target one directly. This needs Xcode with an Apple ID signed in
(Xcode > Settings > Accounts) and that account set as the Team - with
"Automatically manage signing" checked - on the virtualvs target's Signing
& Capabilities tab, in ios/virtualvs.xcworkspace (also gitignored/
regenerated by prebuild, like android/).
There's no App Store distribution path here since this project has no paid Apple Developer Program membership wired up - the section below covers on-device installs, which work with a free Apple ID.
npm test # watch: npm run test:watch
npm run test:summary # suite + a coverage table
open coverage/index.htmlCI runs the same checks on every push and pull request, prints the coverage table on the run's summary page, and attaches the HTML report as an artifact - see docs/RELEASING.md.
- On the phone: Settings > About phone, tap "Build number" 7 times to unlock Developer options, then Settings > Developer options > enable USB debugging.
- Plug in via USB (or
adb connect <ip>over the same network) and accept the "Allow USB debugging?" prompt on the phone. npx expo run:android --device- builds a debug dev-client and installs and launches it directly on the phone, same flow as targeting an emulator.- To install the release APK from above instead:
adb install -r android/app/build/outputs/apk/release/app-release.apk. Android will prompt to allow installs from that source the first time.
- To install the release APK from above instead:
- Cable-connect the iPhone/iPad to the Mac (or pair it wirelessly once via Xcode > Window > Devices and Simulators) and tap "Trust This Computer" on the device if prompted.
- In Xcode, sign in with an Apple ID (Xcode > Settings > Accounts) and set
that Team, as above, on the
virtualvstarget inios/virtualvs.xcworkspace. npx expo run:ios --device- builds and installs directly on the selected device.
A free Apple ID works, but the resulting build's provisioning profile
expires after 7 days, after which the app stops opening on the phone until
you rebuild and reinstall from Xcode/expo run:ios again. A paid Apple
Developer Program membership ($99/year) removes that limit - see
Sponsor if you'd like to help fund it.
Virtual VS is free and GPL-licensed, and it stays that way. If it earns its place on your stage, sponsorship helps in one concrete way right now: an Apple Developer Program membership ($99/year). Without it, iOS builds signed with a free Apple ID expire after 7 days and have to be reinstalled from Xcode - which is the single biggest thing standing between this project and a usable iOS release.
You can also sponsor from the Sponsor button at the top of the repository page.
Not in a position to sponsor? Reporting a bug from a real gig is worth a lot too - the crash and playback fixes in this project all came from someone actually using it.
GPL-3.0-or-later. See LICENSE.
Contributions are accepted under the Developer Certificate of Origin rather than a separate CLA - see CONTRIBUTING.md.