Last updated: 2026-09-05
This guide explains how to embed GramFrame spectrogram viewers in HTML pages. GramFrame auto-discovers configuration tables and replaces them with interactive SVG overlays.
<script src="gramframe.js"></script>For standalone use (no build tool), use the IIFE bundle:
<script src="gramframe.bundle.js"></script>The standalone bundle includes CSS inlined automatically — no separate stylesheet needed.
<table class="gram-config">
<tr><td colspan="2"><img src="spectrogram.png" /></td></tr>
<tr><td>time-start</td><td>0</td></tr>
<tr><td>time-end</td><td>10</td></tr>
<tr><td>freq-start</td><td>0</td></tr>
<tr><td>freq-end</td><td>2000</td></tr>
</table>On DOMContentLoaded, GramFrame scans the page for all <table class="gram-config"> elements and replaces each one with an interactive spectrogram viewer.
Until that scan runs, a config table is ordinary HTML, so on a cold load (large spectrogram, slow network) the browser paints it before GramFrame replaces it. The stylesheet dresses that gap up as a loading placeholder: the parameter rows are hidden, the spectrogram is dimmed back and a "Loading spectrogram" caption sits over it. The same caption then covers the component's image panel until the spectrogram's dimensions are known; if the image never loads, it is replaced with a plain failure message.
For the placeholder to be in effect at first paint, the styling must reach the browser before the config table does — put the <link rel="stylesheet"> (or, for the standalone bundle, the <script> that inlines the CSS) in <head> rather than at the end of <body>.
The configuration table uses a 2-column format: parameter | value.
| Parameter | Type | Required | Description |
|---|---|---|---|
time-start |
number | Yes | Start time value (bottom of Y-axis) |
time-end |
number | Yes | End time value (top of Y-axis). Must be > time-start |
freq-start |
number | Yes | Start frequency value (left of X-axis) |
freq-end |
number | Yes | End frequency value (right of X-axis). Must be > freq-start |
image-sizing |
screen / native / legacy |
No (default screen) |
How big the image is drawn. screen: one image pixel per screen pixel, so a display scaled to 125% or 150% does not enlarge it. native: one image pixel per CSS pixel. Both shrink the image, keeping its shape, only when it would not fit the page's width, and give every gram the expand button. legacy: one image pixel per CSS pixel, capped at 1200 wide whatever the room, with the expand button on landscape grams only |
The first row must contain an <img> element with the spectrogram image (using colspan="2").
- All four parameters (
time-start,time-end,freq-start,freq-end) are required - Values must be a single valid number — the whole cell is parsed, so
1,5,10 Hzand an empty cell are all rejected rather than being read as1,10and0 - Start values must be strictly less than end values
- The first row must contain an
<img>element, and that element must have a non-emptysrcattribute - If validation fails, the original table is preserved and an error indicator is shown
A config table may name a WAV recording instead of an image. GramFrame then decodes the file in the browser, analyses it into a spectrogram, and shows it as a waterfall: press play and the newest sound enters at the top of the gram while everything already shown slides down, in step with the audio. The whole recording is drawn from the moment it loads, so it can be scrolled through, measured and annotated before a note has been played; the annotations scroll with the gram when play resumes.
<table class="gram-config">
<tr><td colspan="2"><audio src="audio/diesel-generator.wav" controls></audio></td></tr>
<tr><td>window-seconds</td><td>10</td></tr>
<tr><td>freq-end</td><td>4000</td></tr>
</table>The first row holds an <audio> element in place of the <img>. Adding
controls to it is optional but recommended: before GramFrame runs, or on a
page where it cannot, the browser shows a plain audio player. Every other row
is optional.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
fft-size |
integer, power of two (64–32768) | 1024 | Samples per analysis frame. Larger gives finer frequency resolution and coarser time resolution. A narrow low band wants a large one: at 16 kHz, 16384 gives 1 Hz per column |
hop-size |
integer ≥ 1 | fft-size / 2 |
Samples between frames — the height of one gram row in samples. Larger makes the gram shorter |
freq-start |
Hz | 0 | Lowest frequency shown |
freq-end |
Hz | half the sample rate | Highest frequency shown. Above the recording's Nyquist frequency it is clamped, with a console warning |
window-seconds |
seconds > 0 | 10 | How much of the recording the unzoomed view spans |
preserve-pitch |
true / false |
true |
Whether a change of playback speed keeps the pitch. false resamples instead, so slowing the recording lowers the pitch with it, as slowing a tape does |
frame-average |
integer 1–64 | 1 | Analysis frames averaged into each painted row. Cuts the speckle a single transform leaves, at proportionally coarser time resolution |
normalisation |
none / split-window / per-bin |
none |
Paint how far each point stands above the background rather than its measured level (see below) |
normalisation-window |
Hz > 0 | 25 bins | How far each side of a bin split-window draws its background from. Stated in hertz so it means the same width at every fft-size |
level-floor |
percentile 0–100 | 5 | Which percentile of the levels is painted the darkest colour |
level-ceiling |
percentile 0–100 | 99.9 | Which percentile is painted the brightest. Must be above level-floor. Not used when level-span is set |
level-span |
dB > 0 | — | Paint the brightest colour this many decibels above the floor instead of at level-ceiling, clipping anything stronger (see below) |
level-scope |
file / row |
file |
What those two percentiles are measured over: the whole recording, or each painted row on its own (see below) |
colour-map |
colour / grey / inferno / magma / viridis / plasma |
colour |
Paint with the colour table; as grey shades (white quietest, black loudest — dark for loud, as a legacy renderer draws); or with one of matplotlib's perceptually uniform maps, whose brightness rises strictly with level. The radio row on the transport bar changes the same choice live, without re-analysing |
time-start and time-end are ignored on an audio table, with a console
warning: the recording defines its own time range, 0 to its duration.
The default painting is honest but blunt: the measured level, with the 5th to 99.9th percentile of the whole file spread across the colour table. Machinery and ship noise falls away steeply with frequency, so the loud low end takes most of that range and a genuine tonal higher up — a decibel or two above its own local background — is painted much the same colour as that background.
normalisation paints how far each point stands above its background instead,
so a weak line reads the same wherever in the band it lies. The two estimators
fail in opposite directions, which is why both are offered:
split-windowestimates the background across frequency, from the bins either side with a guard band left around the bin itself, so a tonal never contributes to the background it is judged against. A line that runs the whole recording survives it intact. It cannot show broadband structure: anything as wide as the window is background by definition.per-binestimates it over time, as each bin's median level. It removes a fixed spectral shape and steady system noise cleanly and keeps broadband events visible — but a tonal present throughout the recording is background to this estimator, and is flattened away with it.
normalisation-window is how far each side of a bin the split window reaches.
The default is 25 bins, which is a different width at every FFT size: 49 Hz at
8192 on a 16 kHz recording, but 98 Hz at 4096 — half of a 200 Hz band, at which
point the "local" background is nearly the row mean and little is removed. State
it in hertz for the band being looked at; a few times the spacing of the lines
of interest is a reasonable start.
frame-average is the other half of the same job: a single transform of noise is
a rough estimate, which is what makes an un-averaged gram speckled. Averaging
four of them halves that speckle so a steady tonal shows through, at the cost of
each row covering four times as much time.
level-floor and level-ceiling decide how much of the level range the colours
are spread over. The clipping at each end happens when the image is painted, so
the contrast sliders on the transport bar — which re-map levels already painted —
cannot recover it. Lowering level-floor towards 0 is what brings the quietest
part of a recording back into the picture.
level-span replaces the ceiling percentile with a fixed number of decibels
above the floor. This is the legacy LOFAR painting: normalise, then map zero to
a dozen decibels onto the shades and clip anything stronger. With a ceiling
percentile the strongest line in the range owns the top of the colour table and
a line 4 dB above background gets a sliver of it; over a 12 dB span that line is
a third of the way up whatever else is present. It is most useful with
normalisation on and level-floor near 50, so the floor sits at the background.
level-scope decides what the floor percentile — and the ceiling percentile, if
a span is not set — is measured over. With file,
the default, there is one range for the whole recording, so a level means the
same loudness everywhere in the picture — and a quiet passage is left with only
the bottom of the colour table to be drawn in, its lines a dull blur beside the
loud passage that took the rest. With row, every painted row is spread over
the table on its own, as the per-line gain of a legacy display does: a quiet
row's energy is as sharply bounded as a loud row's, because no other row can
take its colours. The price is that nothing in the picture says which row was
louder. It acts after normalisation, on whatever the estimator left.
The trial page puts all of these on screen as controls, for choosing the values an exercise should ship with.
WAV only: PCM 8, 16, 24 or 32-bit or 32-bit float, mono or stereo (stereo is
mixed to mono for both analysis and playback). Keep recordings to a few
minutes. The analysed gram is capped at 32,768 rows by 4,096 columns. A
recording that would be too tall is drawn at the coarser hop-size that
fits, with a caption under the gram naming what was asked for and what was
used — it loads rather than being refused. The cap is on the painted rows, so
frame-average counts against it: averaging four frames to a row is four times
as much recording inside the same limit, and is the way to keep a fine
hop-size on a long file. A gram too wide is still refused
with the standard error indicator, since no substitution rescues it: lower
fft-size or narrow the frequency range. Three minutes at 44.1 kHz with the
defaults is about 15,500 rows.
A page opened from the file system cannot fetch a sibling WAV — browsers block it. Generate a sidecar next to each recording once:
node scripts/wav2js.mjs audio/diesel-generator.wav
# writes audio/diesel-generator.wav.jsShip the .wav.js beside the .wav. GramFrame looks for it only when the
fetch fails, so pages served over HTTP never load it. The sidecar is the WAV
base64-encoded (about a third larger), so a five-minute 44.1 kHz recording
costs some 35 MB on disk; 22,050 Hz material halves that and still covers
everything below 11 kHz.
The bar under the gram offers play/pause, restart, a seek slider, the visible
time span, loop, playback rate (0.25× to 4×), mute and volume; a click on the
time axis also seeks. When a player has keyboard focus (click on it), Space
or K toggles play, J/L seek 5 s back or forward (30 s with Shift),
Home restarts and M mutes. Arrow keys keep nudging a selected annotation.
Image-backed grams are unaffected by any of these.
Changing the playback rate keeps the pitch, so what is heard still matches what
the frequency readout says; preserve-pitch false in the config table selects
resampling instead. Either way the gram is never re-analysed, so the readouts
stay true.
Press and drag the gram while it plays: playback pauses under your hand, the
view follows the drag, and releasing resumes from the time the view was left
at — including when you let go outside the component. Click it instead of
dragging and it simply pauses, in any mode. In Pan mode a second click resumes,
so a click is the play/pause toggle; in the annotation modes a click on a
paused gram keeps its usual meaning and places a feature, so resume there with
the play button or Space. The wheel still zooms
while playing (the view keeps the playhead at its top edge, and the bar states
the span in seconds). Placing, moving, restyling and deleting annotations stay
inert until you pause, and Shift-drag region zoom is likewise a paused-only
gesture.
Two sliders on the transport bar — a floor and a ceiling — re-map the drawn
levels live, to lift a faint tonal out of the background; Reset returns the
picture to exactly how it loaded. They change how the gram looks and nothing
else: every readout, annotation and saved value is untouched. They act on the
painted image, so detail the analysis already clipped cannot be recovered by
them, and they appear on audio-sourced grams only — an author-supplied PNG is
shown as it was made.
The expand toggle (⤡) at the top-left of the gram works as it does on an image: it grows the axes area to fill the window, with the transport bar kept in view, and shows the most detail the screen allows. Zoom and pan compose with it.
From script, GramFrame.getPlayer(index) returns the player of the
index-th instance (null for an image-backed one) with play(), pause(),
seek(seconds), restart(), setLoop(), setPlaybackRate(), setVolume() and
setMute(). play() returns the element's promise, which rejects if the
browser refuses to start audio without a user gesture. The broadcast state
carries a player object with the duration, playhead, transport flags and
the analysis parameters in force.
You can have multiple independent GramFrame instances on a single page. Each config table becomes its own instance with independent state.
<!-- First spectrogram -->
<table class="gram-config">
<tr><td colspan="2"><img src="spectrogram-1.png" /></td></tr>
<tr><td>time-start</td><td>0</td></tr>
<tr><td>time-end</td><td>30</td></tr>
<tr><td>freq-start</td><td>0</td></tr>
<tr><td>freq-end</td><td>5000</td></tr>
</table>
<!-- Second spectrogram (different image and ranges) -->
<table class="gram-config">
<tr><td colspan="2"><img src="spectrogram-2.png" /></td></tr>
<tr><td>time-start</td><td>0</td></tr>
<tr><td>time-end</td><td>60</td></tr>
<tr><td>freq-start</td><td>100</td></tr>
<tr><td>freq-end</td><td>20000</td></tr>
</table>Each instance:
- Has its own state (cursor position, mode, markers, etc.)
- Responds independently to mouse interactions
- Can be in different modes simultaneously
- Gets a unique
instanceIdfor programmatic access
If you need to initialize GramFrame after page load (e.g., for dynamically added content):
// Initialize all config tables in a specific container
const container = document.getElementById('my-container')
const instances = GramFrame.detectAndReplaceConfigTables(container)Listen for state changes across all instances:
// Add a listener
const listener = GramFrame.addStateListener(state => {
console.log('Mode:', state.mode)
console.log('Cursor:', state.cursorPosition)
})
// Remove a listener
GramFrame.removeStateListener(listener)State is deep-copied before being passed to listeners, so you cannot accidentally mutate internal state.
A gram can be expanded to fill the space around it — any gram under the
default image-sizing, landscape grams only under legacy. The toggle is a
button on the component; these two methods drive the same state from a host
page:
// Is the first instance currently expanded?
const expanded = GramFrame.getExpandState()
// Expand (or collapse) every instance on the page that has the toggle
GramFrame.setExpandState(true)Expand state is in-memory only — deliberately not persisted, so a reload starts collapsed.
GramFrame can persist annotations (analysis markers, harmonic sets, sideband sets, doppler curves) in browser storage. The storage backend depends on whether the page is detected as a trainer page or a student page:
- Trainer pages use
localStorage— annotations persist permanently, so an instructor can author them once and have them survive browser restarts. - Student pages use
sessionStorage— annotations are ephemeral and cleared when the browser tab/session closes.
A page is treated as a trainer page if any of the following explicit flags is present anywhere in the page, in order of preference:
| Form | Example | Notes |
|---|---|---|
| Class | <span class="gf-persistent"></span> |
Recommended — DITA-friendly |
| Data attribute | <span data-gf-persistent></span> |
DITA-friendly |
| Id | <span id="gf-persistent"></span> |
Legacy; kept for backward compatibility |
The flag element can be hidden and placed anywhere on the page — detection runs over the whole document with no ordering constraints.
<!-- Mark this page as a trainer/instructor page -->
<span class="gf-persistent" hidden></span>A legacy heuristic also treats a page as trainer context if it contains an
anchor whose exact text is ANALYSIS. This is fragile (it false-positives on
any page with such a link) and is retained only for backward compatibility —
prefer an explicit flag above.
The AAAC training material is produced through a DITA-OT / Oxygen WebHelp
publishing pipeline. DITA-OT topic-scopes and uniquifies every @id in its
HTML output, so an authored id="gf-persistent" is rewritten to something
page-specific (e.g. id="ariaid-title1_gf-persistent") and
getElementById('gf-persistent') never matches — instructor pages would
silently fall back to ephemeral sessionStorage.
A DITA @outputclass, by contrast, is passed straight through to the HTML
@class verbatim and un-mangled (this is exactly how table.gram-config
itself is detected), and classes are not uniquified. So .gf-persistent is
reliably emittable from DITA and stable on every page.
DITA integrators add the class to an instructor-only marker they already emit (profiled out of the student build via DITAVAL), so no extra authoring is required — students get no flag and stay ephemeral:
<p outputclass="gf-persistent" audience="instructor">…</p>→ renders to class="p gf-persistent" on instructor pages only. No id, no
post-processing, no client-side shim.
GramFrame supports file:// protocol for offline use. The standalone IIFE build (gramframe.bundle.js) bundles all CSS inline, avoiding cross-origin restrictions. See ADR-013.
Build the standalone bundle with:
yarn build:standalone- Verify the table has
class="gram-config"(exact class name) - Check the browser console for error messages
The script may be loaded at any point — before DOMContentLoaded or long after.
A bundle injected late initialises immediately instead of waiting for an event
that has already fired (issue #272), so a lazily-appended <script>, a deferred
loader, or a DITA/HTML5 output that scripts its own includes all work. To add
tables after that, call GramFrame.detectAndReplaceConfigTables(container).
If a red error box appears below the table:
- "No image element found" — First row must contain an
<img>tag (or an<audio>for a player) - "Audio-sourced gram failed" — the recording could not be fetched or decoded, or its gram would exceed the size cap; the message says which. Over
file://, check the.wav.jssidecar is beside the WAV - "Image element has no src" — The
<img>needs a validsrcattribute - "Missing required time/frequency configuration" — All four parameters must be present
- "Invalid time/frequency range" — Start value must be less than end value
- "Invalid numeric value" — Parameter values must be numbers
- Verify the image path is correct relative to the HTML file
- For
file://protocol, ensure the image is in an accessible directory - Check browser console for 404 errors
Both symptoms have the same cause: the page was detected as a student page (see Annotation Persistence). Nothing removes the button after the component starts, so a page that once had it and now does not was never a trainer page to begin with — the flag is missing from that page, or arrived too late.
To check, on the page in question:
- Inspect the component's container: it carries
data-gf-context="trainer"ordata-gf-context="student". - Open the browser console. Each instance logs one line on start-up, e.g.
GramFrame: instance 0 is on a student page (no gf-persistent flag … and no "ANALYSIS" anchor was on the page when the component initialised). On a trainer page the line names the element that matched.
Common reasons a page in an instructor publication comes out as student:
- The topic has no
gf-persistentflag — it was omitted, or profiled out. Every topic needs its own flag; detection does not carry over between pages. - The page relies on the legacy
ANALYSISanchor and that navigation link is built by script afterDOMContentLoaded. Detection runs once, at start-up, and is not re-evaluated — use an explicit flag in the topic body instead. - The flag sits inside the
gram-configtable, which is removed when the first gram on the page is built, so a second gram on the same page does not see it. Put the flag outside the table.
Note that on a student page annotations go to sessionStorage and expire after
24 hours, so a missing button also means the work will not persist.
Each instance is fully independent. If instances seem to interfere:
- Verify each table has its own
<img>element (not shared) - Check that state listeners are filtering by
instanceIdif needed
- ADR-005: HTML Table Configuration — Design rationale for the table-based config approach
- ADR-013: File Protocol Compatibility — Offline/file:// support decisions
- Tech-Architecture.md — Full system architecture