Skip to content

Latest commit

 

History

History
467 lines (360 loc) · 23.6 KB

File metadata and controls

467 lines (360 loc) · 23.6 KB

HTML Integration Guide

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.

Quick Start

1. Include the Script

<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.

2. Add a Configuration Table

<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>

3. Component Auto-Initializes

On DOMContentLoaded, GramFrame scans the page for all <table class="gram-config"> elements and replaces each one with an interactive spectrogram viewer.

Before the Component Appears

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>.

Parameter Reference

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").

Validation Rules

  • 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 Hz and an empty cell are all rejected rather than being read as 1, 10 and 0
  • Start values must be strictly less than end values
  • The first row must contain an <img> element, and that element must have a non-empty src attribute
  • If validation fails, the original table is preserved and an error indicator is shown

Audio-Sourced Grams (the Spectrograph Player)

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.

Making a faint tonal visible

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-window estimates 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-bin estimates 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.

What the recording may be

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.

Serving over file://

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.js

Ship 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.

Playback and keys

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.

Moving around a recording that is playing

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.

Contrast

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.

Multiple Instances

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 instanceId for programmatic access

Programmatic Initialization

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)

State Listener API

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.

Expand API

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.

Annotation Persistence (Trainer vs. Student)

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.

Why a class and data-attribute, not just an id

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.

File Protocol Compatibility

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

Troubleshooting

Table Not Replaced

  • 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).

Error Indicator Shown

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.js sidecar is beside the WAV
  • "Image element has no src" — The <img> needs a valid src attribute
  • "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

Image Not Loading

  • 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

Clear Gram Button Missing, or Annotations Not Persisting

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" or data-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-persistent flag — 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 ANALYSIS anchor and that navigation link is built by script after DOMContentLoaded. 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-config table, 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.

Multiple Instances Interfering

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 instanceId if needed

Related Documentation