@ugfoundation/swaralipi-js
v0.17.0
Published
JSON spec, Unicode-Bengali viewer, and Tone.js player for Rabindrasangeet swaralipi (আকারমাত্রিক notation).
Maintainers
Readme
@ugfoundation/swaralipi-js — a JSON spec, a Unicode-Bengali viewer, a Tone.js player, and an archival-HTML converter for Rabindrasangeet swaralipi (Tagore's আকারমাত্রিক / akarmatrik notation).
The spec is the contract: write (or generate) a swaralipi JSON document, then hand the same document to the viewer, the player, or the printer. Built for gitabitan.io, published standalone under GPL-2.0-only.
pnpm add @ugfoundation/swaralipi-js # or npm / yarnQuick start
The smallest useful program: validate a document and render it.
import { parseSwaralipi } from '@ugfoundation/swaralipi-js/spec';
import { renderToHtml, SWARALIPI_CSS } from '@ugfoundation/swaralipi-js/viewer';
const doc = parseSwaralipi({
version: '0.1.0',
meta: { title: 'অকারণে', taal: { id: 'dadra', numBeats: 6 }, tonic: 'C' },
parts: [{ name: 'asthayi', rows: [{ notes: [
{ svara: ['madhyam', 'm'], lyric: 'অ' },
{ svara: ['madhyam', 'p'], lyric: 'কা' },
{ sustain: true, lyric: '৹' },
{ bar: 'single' },
] }] }],
});
document.head.insertAdjacentHTML('beforeend', `<style>${SWARALIPI_CSS}</style>`);
document.body.innerHTML = renderToHtml(doc, { lang: 'bn' });Writing a swaralipi document
A document is a single JSON object: { version, meta, parts }. A part is a section (আস্থায়ী / অন্তরা …) holding rows (written lines); a row holds notes (the tokens below). Build it by hand, generate it from a tool, or produce it with the converter. Everything else in this package consumes this shape.
{
"version": "0.1.0",
"meta": { /* see below */ },
"parts": [
{ "name": "asthayi", "rows": [ { "notes": [ /* tokens */ ] } ] }
]
}meta
Only title is required. The rest is optional but drives playback and the header.
"meta": {
"title": "অকারণে অকালে মোর পড়ল যখন ডাক",
"taal": { "id": "dadra", "numBeats": 6, "bhaags": [3, 3], "sam": 0, "khaali": [3] },
"taalName": "দাদরা", // verbatim source name (use when `taal.id` is unknown)
"tonic": "C", // base pitch — "C", "C#"/"cs", "Db" all accepted
"bpm": 144, // default tempo, matra/min (player only; viewer ignores it)
"avartan": 2, // আবর্তন count, when the source states it
"notationBy": "…", "source": "…" // provenance
}svara — pitched notes
A pitch is a [octave, letter] tuple. Octaves: ati-mandra, mandra, madhyam, taar, ati-taar. Letters: s r g m p d n (d=ধৈবত/dha, n=নিষাদ/ni). Sharps/flats are flags, not letters:
{ "svara": ["madhyam", "g"] } // শুদ্ধ গ
{ "svara": ["madhyam", "g"], "komal": true } // কোমল গ (flat)
{ "svara": ["madhyam", "m"], "tivra": true } // কড়ি/তীব্র ম (sharp)
{ "svara": ["taar", "s"] } // taar সা (high)
{ "svara": ["mandra", "p"] } // mandra প (low)
{ "svara": ["madhyam", "r"], "microtone": "atikomal" } // sub-komal: alpakomal | atikomalduration — matra groups
Default duration is 1 মাত্রা. Use durationMatra for fractional or held notes; a run of fractions summing to a whole matra renders as one beamed group (nsfa → নর্সা):
{ "svara": ["madhyam", "n"], "durationMatra": 0.5 } // half-matra
{ "svara": ["taar", "s"], "durationMatra": 0.5 } // …grouped with the one above
{ "svara": ["madhyam", "p"], "durationMatra": 2 } // held two matrashalfMatra: true prints ঃ after its note; if it ends only the first half, the cell stays open (-ঃমঃ, সঃ গরঃ = one matra, one column). A hand-set ঃ or aakar overrides the source raw marks in that cell. aakar: false hides that note’s া on the sheet; duration and playback stay unchanged, and rests always show their sign.
sustain (আ-কার) and melisma
{ "sustain": true, "lyric": "৹" } // hold prev pitch+vowel (আ-কার)
{ "svara": ["taar", "s"], "sustain": true, "lyric": "৹" } // melisma: pitch moves, vowel continuesrest (বিরাম)
{ "rest": true, "durationMatra": 1 } // silence for one matrabars — দাঁড়ি / দণ্ড
{ "bar": "dari" } // । vibhag separator inside the cycle
{ "bar": "single" } // I end of one taal cycle (ফেরা)
{ "bar": "double" } // II section / return-to-asthayi boundary
{ "bar": "quad" } // IIII end of the songbrackets — repeat / skip / alt
A bracket spans a range; the first note carries edge: 'open', the last edge: 'close'.
repeat = {} গুন্ডবন্ধনী · skip = () omit on repeat · alt = [] changed-on-repeat.
{ "svara": ["madhyam", "s"], "brackets": [{ "kind": "repeat", "edge": "open" }] }
{ "svara": ["madhyam", "r"], "brackets": [{ "kind": "repeat", "edge": "close" }] }kan (grace) and meend (glide)
{ "svara": ["madhyam", "n"], "kan": ["taar", "s"] } // কণ grace before the note
{ "svara": ["madhyam", "p"], "meend": "start" } // মীড় slur start …
{ "svara": ["madhyam", "g"], "meend": "end" } // … and end
{ "svara": ["madhyam", "d"], "meend": "both" } // ends one মীড় and starts the nextlyrics and annotations
lyric rides on the note (column-aligned, per-syllable). above / below attach recursive notes for alt-on-repeat passages, stop marks, or beat labels. An above-note’s own above draws a second line over it, as in স্বরবিতান; only rows that use it grow, and sam ticks / ‖ there count for playback too:
{ "svara": ["madhyam", "m"], "lyric": "অ",
"above": [{ "svara": ["madhyam", "p"] }] } // alt note printed above
{ "above": [{ "raw": "[qa", "above": [{ "raw": "1f" }] }] } // taal mark over [ ] notesraw — lossless passthrough
Anything the converter can't decode is preserved verbatim in raw and rendered dimmed — the spec is lossless by construction, nothing is silently dropped. You rarely write raw by hand.
{ "raw": "w", "below": [/* … */] }Validate what you wrote
import { parseSwaralipi, safeParseSwaralipi } from '@ugfoundation/swaralipi-js/spec';
parseSwaralipi(json); // returns typed Swaralipi, throws ZodError if invalid
safeParseSwaralipi(json); // { success, data | error } — no throwSee fixtures/ for worked documents — octaves, microtones, matra-durations, repeat-brackets, and the hand-checked akarone anchor.
Deploying the document
View: <swaralipi-sheet>
Renders the document as Unicode Bengali + a CSS grid — no images, no embedded font, no framework. Arcs, kan, bars, beat labels and annotations all render.
<script type="module">
import { defineSwaralipiSheet } from '@ugfoundation/swaralipi-js/viewer';
defineSwaralipiSheet();
</script>
<swaralipi-sheet src="/notation/4270.json" lang="bn"></swaralipi-sheet>Pass a URL via src or set the doc property directly. lang is bn | hi | en (Devanagari for hi, roman + octave dots for en); hide-header; highlight('part.row.note') for player sync. Styles are shadow-DOM-encapsulated, themeable via --sw-font, --sw-size, --sw-cell-gap, --sw-matra, --sw-header-min, --sw-hl.
SSR / static sites (Astro etc.) — renderToHtml is a pure function (string in, string out, no DOM); emit it alongside SWARALIPI_CSS. See Quick start.
Responsive / zoom
The sheet is one shared grid with one natural width, so it has to be fitted to a narrow screen. Two levers run in order, because they cost different things:
- Column spacing —
--sw-cell-gapplus the--sw-matracolumn floor are 25–35 % of the natural width, and giving them up costs only air between the columns (the alignment is untouched: every row still borrows the same tracks). Compressed only as far as the container demands, down tomin-spacing. - Type size — then the sheet's
font-sizescales (font-size, nottransform— keeps print pagination intact), stopping atmin-sizepx. Past that the block pans inside its own scroller rather than becoming unreadable.
<!-- how tight the columns may get (0–1, default 0.35) and the smallest
notation size in px the fit may produce (default 12) -->
<swaralipi-sheet src="…" min-spacing="0.35" min-size="12"></swaralipi-sheet>sheet.minSpacing = 0.2; // tighter columns, bigger glyphs (attribute-reflected)
sheet.minSize = 14; // never below 14px; scroll instead
sheet.minSpacing = 1; // opt out of compression entirely (old behaviour)Measured on a 390 px phone: a দাদরা page goes from 11.7 px to 15.5 px type and still fits without scrolling; a wide তিনতালা goes from 6.1 px to the 12 px floor and pans. The নাম/তাল/আবর্তন/স্বরলিপিকার header keeps its own floor (--sw-header-min, default 0.95rem) — it is prose with no columns to align, so it stays legible while the notation shrinks.
Build your own zoom UI (buttons, a slider) against the element:
sheet.zoom; // current zoom factor, 1 = fit-to-width
sheet.zoomIn(); // zoom *= ZOOM_STEP (1.2), clamped
sheet.zoomOut(); // zoom /= ZOOM_STEP, clamped
sheet.resetZoom(); // back to fit-to-width
sheet.zoom = 1.5; // or set an explicit factor directly (clamped, re-fits)MIN_ZOOM/MAX_ZOOM/ZOOM_STEP are exported from /viewer so a custom slider's bounds match the clamp. Zoom resets to fit-to-width on the next doc/lang change; a plain resize (rotating the phone) keeps it. Past 100% the sheet pans via native scrolling — same as zooming a photo. Pinch-to-zoom is left to the browser's own page zoom (we never set touch-action: none, so it isn't blocked); the buttons above are the explicit, always-available control.
SSR/manual-DOM consumers without the custom element call the same mechanism directly: fitSwaralipi(container, minScale?, zoom?, minSpacing?, minSize?) (all optional, /viewer export). minScale is a second, relative floor used by the print path, where paper cannot scroll.
Print / PDF
import { printSwaralipi, renderPrintDocument } from '@ugfoundation/swaralipi-js/viewer';
printSwaralipi(doc, { lang: 'bn', title: 'song-name' }); // hidden-iframe → print dialog
const page = renderPrintDocument(doc); // full HTML string (new tab / headless)document.querySelector('swaralipi-sheet').print() is the element shortcut. PDF fidelity comes from the browser's own engine — the same Indic shaping (reph, conjuncts, matra) and CSS-drawn marks as the screen — so we deliberately avoid html2canvas/jsPDF, which can't shape Indic text.
Any paper, any orientation. The print is fitted to the width of the paper picked in the dialog and centred, and the browser breaks the pages for its height — again each time the paper or orientation changes.
The watermark is on every page, in every engine. WebKit (Safari, and every iOS browser) paints a position: fixed box once, centred on the whole document (bug 6790), so there the print decides its own page breaks and pins one mark per page. A consumer printing its own document can do the same: paginate(doc, { atoms, keep?, starts?, pageHeight, mark: 'sw-page-mark' }) (with watermarkCss(text) and printPageHeight(widthPx, heightMm), which is shorter in WebKit, whose page size CSS cannot set).
Play: SwaralipiPlayer
A Tone.js player turns a document into audio — melody on santoor / harmonium / violin / flute / grand piano, a tabla and a tanpura drone from tabla-js — and drives the viewer's highlight in sync. Tone and tabla-js are dynamically imported on first play(), so importing the module never touches AudioContext — safe in Node/SSR.
import { SwaralipiPlayer } from '@ugfoundation/swaralipi-js/player';
const player = new SwaralipiPlayer(); // zero-config: samples from the project CDN
player.load(doc);
player.bindSheet(document.querySelector('swaralipi-sheet'), { autoScroll: true });
playButton.onclick = () => player.play(); // must be a user gesture (AudioContext)Controls: setTonic('C#'), setBpm(120), setInstrument(id), setTaal(id|null), setVariation(name|null), setFillers('auto'|'light'|'busy'|'off'), setTihai(bool), setRhythm('tabla'|'drums'|'metronome'|'none'), setTanpura(bool), setCountIn(bool), pause() / stop() / dispose(). Each song loads only the samples covering its pitch range.
- Self-host samples: override
soundsBaseUrl(layout:santoor/<note>.mp3,tabla/bols/<stroke>.wav,tanpura/strings/<set>-<string>.wav,metronome/metro2.mp3). - Add instruments:
customInstruments: { sitar: { label, urls, baseUrl } }. - Liveness:
await player.checkInstruments()→{ id: available }, to grey out picker options whose samples are down.
The pure, audio-free scheduler is exported too (buildSchedule, planRhythm, svaraToMidi, parseTonic, INSTRUMENTS, …) for a custom transport.
The tabla is synthesised from single bols. tabla-js keeps each theka as data and plays it from one sample per stroke, at any tempo and in tune with the song's Sa. swaralipi-js only conducts it, so every stroke lands on its matra (measured: 0.000 ms off the transport):
- Taals: the 24 that gitabitan.io uses and tabla-js has (
TABLA_TAALS). Any other id still plays: tabla-js finds a taal of the same shape, or a plain theka on its vibhags. - Variations:
meta.taal.variation(or a row'staal.variation) picks one by name, any case. Omitted, each taal plays its default, listed insrc/player/tabla-defaults.ts.tablaVariations(taal)lists them for a picker. - Fillers (
meta.fillers, defaultauto): a filler cycle wherever the singer holds or rests through a cycle's last vibhag, never twice running.autoplays light ones and every 4th busy. - Tihai (
meta.tihai, default off): closes the song, landing on the first সম at or after its last note. - Custom taal: any
idnot in the catalogue, with its ownnumBeats,bhaags,samandkhaali. It plays a catalogue taal of the same vibhags and khaali, else a plain theka on its vibhags. Asamon another vibhag means each line opens before sam: the rhythm waits for it, and its theka is written from sam. The creator makes one ("Custom taal…", or "Start blank"). - Custom theka: any taal can carry its own
theka, fillers,tihaiandbols— tabla-js's swaralipi-taal shape, as its composer exports it. - সম marks re-sync it. Every
1f/2Fmark is a সম. If one comes early or late, whole cycles play, then the theka's last matras lead into the mark. A gap shorter than one cycle cuts that cycle. The metronome, the drums and the MIDI drums follow the same plan. - Tempo: any bpm; while a tabla plays it snaps to
TABLA_BPMS(15–720, step 15). - The metronome claps the taal: sam loudest, a tali 4 dB under it, a plain matra 8, a khali 14 (
clickLevels). - Tanpura: tabla-js's five-string tanpura, a drone at its own pace, an octave under the melody's Sa.
It sings the song's form, not just the page. { } twice, ( ) only the first time, the [ ] notes written above on the repeat. At each kali's closing II it goes back to the asthayi's opening II and sings up to the ‖ stop mark (I[ ]I sings the [ ] notes there). The tabla keeps its cycle through every jump. Existing JSON needs no change. repeats: false plays it as written; exportAudio / exportMidi take the same option. buildSchedule(doc, { repeats: true }) gives the sung timeline (its default stays as written). What is not handled yet: REPEATS.md.
The staff view prints the same form in Western marks, so a musician reading it plays what the player plays:
| Swaralipi | Staff |
|---|---|
| { } | 𝄆 𝄇 repeat bars |
| ( ) up to } | 1st-time bar (volta "1"); elsewhere "(1st time only … )" |
| [ ] notes above | a second voice over that spot, "2nd time" (or "on D.S." for I[ ]I) |
| asthayi's opening II | 𝄋 segno |
| ‖ | To Coda / FINE |
| kali's closing II | D.S. al coda, then ⊕ where singing goes on; D.S. al fine at the end |
| II · IIII | double bar · end bar |
| রেশ · লয় / তাল change | after-grace · its name above the line |
A return that passes a { } says "with repeats on D.S." (a Western D.S. skips repeats otherwise). With drums, the drum staff takes every bar the melody has.
A pickup is sung first; the rhythm comes in on the সম. Many songs start before the সম (অনাগত). The সম comes from a 1f/2f tick. With no tick, a song that opens on loose notes takes it from its bars: the first line that opens on a bar starts on sam, else the first I. The melody starts at once; tabla, drums or metronome come in on the সম; the tanpura sounds from the start. countIn: true (or setCountIn(true)) puts the rhythm first: it plays the rest of the cycle and the melody comes in on its own beat. exportAudio and exportMidi take the same option, and the staff view writes the pickup as a short first bar. buildSchedule(doc) gives samBeat (where the rhythm comes in) and pickupBeats (the count-in).
Practice: start anywhere, repeat a line, sing it back
Learning a song means hearing one line again and again, then singing it back. Once bindSheet has run, the sheet is the practice surface — no progress bar to fiddle with:
- Tap any line to play from the note under your finger (the whole line is the target; it picks the nearest note). Works playing, paused or stopped.
- Keys, while the sheet has focus:
Spaceplay/pause ·↑↓line ·←→note ·HomeEnd·Rrepeat the line ·Tmy turn ·Escstop repeating ·Shift+↑/↓or Shift+tap stretch the repeat over more lines.
player.play('0.3.2'); // from part 0, line 3, note 2 (or '0.3' for the line's start)
player.stepLine(1); // next line; stepLine(-1) restarts the line first, like ⏮
player.setLoop(); // repeat the line under the playhead (setLoop('0.2', '0.4') for a range, null to stop)
player.setEcho(true); // after each pass, one silent pass: tabla + tanpura keep time,
// the highlight moves hollow — the learner's turn to sing itA line sung more than once (a { }, a return to the asthayi) plays from the pass nearest the playhead, and ↑ ↓ walk the sung order. A repeat follows the playhead (tap or step to another line and it moves there), and a tempo or tonic change keeps both the repeat and the place. Wire buttons to the same calls and mirror them with onLoop: ({ loop, echo, yourTurn }) => …. A tap is a cancelable swaralipi-seek event on the sheet — preventDefault() it in a capturing listener to gate a play (a quota, say). previewLimitSec counts listening time, so repeats cannot run past it.
Follow the words, play an excerpt
alignLyrics(doc, text) places each note in the song's own text: 'part.row.note' → [start, end) offsets into text. It reads the notation's phonetic syllables (আ|বা|র্, চি|ৎ|ত) and its written-out refrains. A held ৹ has no entry.
import { SwaralipiPlayer, alignLyrics } from '@ugfoundation/swaralipi-js/player';
const lyrics = alignLyrics(doc, songText);
const player = new SwaralipiPlayer({
onHighlight: (path) => path && lyrics.has(path) && markWord(lyrics.get(path)), // a repeat maps to the same words
});
// A dance drama quotes lines 1–4, then line 13: play only those, in order.
player.load(doc, { excerpt: { lyrics, ranges: [[line1Start, line4End], [line13Start, line13End]] } });- Each range plays the song's form while its words stay inside it:
{ }twice, a return to the asthayi included. - Between ranges, a short rest lands the next one on its own beat. The tabla never breaks.
- Words the notation does not sing play nothing.
durationSec, seeks and repeats keep to the excerpt.buildSchedule(doc, { repeats: true, excerpt })gives its timeline.
Export audio (WAV / MP3)
Render a document to a downloadable audio file entirely in the browser — an OfflineAudioContext bounces the same melody / tabla / tanpura arrangement faster than real time, so nothing hits a server (works on Chrome, Firefox, Safari incl. iOS, Edge, Android, PWAs).
import { exportAudio } from '@ugfoundation/swaralipi-js/player';
const { blob, filename } = await exportAudio(doc, {
melody: true, // the notation voice (default on)
rhythm: 'tabla', // 'tabla' | 'drums' | 'metronome' | 'none' (default 'none')
taal: 'auto', // a taal id, or 'auto' from meta.taal
tanpura: true,
format: 'mp3', // 'wav' (default) | 'mp3'
});
Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: filename }).click();Also honours instrument, tonic, bpm, variation, fillers, tihai, mp3Kbps (192), channels (mono by default — the content is monophonic), sampleRate, and soundsBaseUrl. WAV is 16-bit PCM and needs no dependency; MP3 needs the optional peer dependency @breezystack/lamejs — see License.
excerpt plays only some lines (as the player's does); lyrics: songText returns cues — each sung line and word in seconds on the file's own timeline, ready for an LRC sidecar. mp3Stream() encodes PCM fed in pieces as one MP3, and exportMidiSet(items, name) writes several songs back to back into one .mid (a tempo and a marker at each song).
Convert: archival HTML → JSON
Digitised swaralipi is commonly published as HTML grids typeset in the ASCII-encoded Swarabitan webfont, where each cell is a glyph code rather than text. The converter decodes that encoding into spec JSON, losslessly — every cell keeps its original code alongside the decoded note, so nothing is lost even where a cell can't be interpreted.
swaralipi convert song.html # spec JSON to stdout
swaralipi convert html/ -o out/ --report out/_report.jsonimport { convertNltrHtml } from '@ugfoundation/swaralipi-js/convert'; // Node-only subpath
const { doc, report } = convertNltrHtml(html, { nltrId: 4270 });Subpath exports
| Import | Environment |
|--------|-------------|
| @ugfoundation/swaralipi-js (root, re-exports /spec) | any |
| @ugfoundation/swaralipi-js/spec | any |
| @ugfoundation/swaralipi-js/viewer | browser / SSR |
| @ugfoundation/swaralipi-js/player | browser (Node/SSR-safe import) |
| @ugfoundation/swaralipi-js/convert | Node only |
| swaralipi CLI | Node |
Develop
pnpm install
pnpm build # tsup → dist/ (ESM + CJS + .d.ts)
pnpm test # vitest
pnpm check # tsc --noEmitNode 20 LTS (.nvmrc), pnpm 9. Contributions are welcome under a CLA — see CONTRIBUTING.md.
Credits
- Rabindranath Tagore's notation is in the public domain (copyright expired in India, 2001).
- sargam-spec by Studio Kalavati (EPL-2.0) — swaralipi-js's spec is an independent reimplementation that extends sargam-spec's format; we keep its
[octave, letter]svara tuple and octave/letter conventions. - Fonts and audio samples are third-party works with their own licences — see THIRD-PARTY.md.
License
GPL-2.0-only © Upal & Gopa Das Foundation. See LICENSE.
Optional MP3 export. exportAudio(…, { format: 'mp3' }) uses @breezystack/lamejs, which is LGPL-3.0 — a different licence from this package. It is declared an optional peer dependency, is loaded only via a dynamic import() when you actually request MP3, and is never bundled or redistributed by swaralipi-js, so it does not affect this package's GPL-2.0-only licensing. Install it yourself only if you want MP3 output; WAV export needs no extra dependency. If you ship an application bundle that includes lamejs, meeting its LGPL-3.0 terms (e.g. allowing the library to be replaced) is your responsibility.
