Basic Annotation
Add annotation capability to your application. Please read Basic Spectrogram and Basic Audio before continuing.
Your App, Your Data
Specviz is 100% type-safe and extends this safety even to your application's domain-specific data. TypeScript is not required but it is highly recommended. Start by defining the type for your user data.
type UserData = {
label?: Label
}
enum Label {
Whale = "🐋",
Shark = "🦈",
Pufferfish = "🐡",
Fish = "🐠",
}
Specviz makes no assumptions about your application's data. To render your annotations, you will need to write a React component. Here we use Specviz.Note.AnnotationProps<T> with our UserData to type our component. Use the provided default properties in props.svgProps and supply any other text, shapes, or styles as needed.
function MyAnnotation(props: Specviz.Note.AnnotationProps<UserData>) {
return (
<svg {...props.svgProps} cursor="pointer">
<rect
fill={props.selected ? "chartreuse" : "violet"}
fillOpacity="0.7"
style={{ mixBlendMode: "hue" }}
/>
<text
x="8"
y="8"
children={props.region.properties?.label ?? "🤷🏽"}
fontSize="30"
/>
</svg>
)
}
Context Provider
Next we will setup a basic context for our application's custom data. This is React Context 101.
type Context = {
label: Label
setLabel: (label: Label) => void
}
const defaultContext: Context = {
label: Label.Whale,
setLabel: () => {
throw Error("setLabel not implemented")
},
}
const Context = React.createContext(defaultContext)
In your application's provider, use Specviz.Note.Provider to supply the initial regions and annotation component to you application. Use Specviz.Note.RegionState<T> with your UserData to type your regions.
function AppProvider(props: { children: React.ReactNode }) {
const [label, setLabel] = React.useState(defaultContext.label)
const [regions, setRegions] =
React.useState<null | Specviz.Note.RegionState<UserData>>(null)
React.useEffect(() => {
fetch("./basic-annotation.json")
.then(r => r.json())
.then(entries => setRegions(new Map(entries)))
}, [])
if (regions == null) return "Loading regions..."
return (
<Context.Provider value={{ label, setLabel }}>
<...>
<Specviz.Note.Provider
initRegions={regions}
render={MyAnnotation}
>
{props.children}
</Specviz.Note.Provider>
</...>
</Context.Provider>
)
}
Basic Controls
Specviz.Note and Specviz.Audio contain many useful functions. You can create your own buttons to bind them in whatever way will be useful for your application.
function NoteControls() {
const app = React.useContext(Context)
const note = Specviz.Note.useContext()
return (
<>
<button
children="Clear Selection"
disabled={note.selection.size == 0}
onClick={() => note.setSelection(new Set())}
/>
<button
children="Delete"
disabled={note.selection.size == 0}
onClick={() => note.deleteSelection()}
/>
</>
)
}
Here we use audio.state to conditionally display a Play or Stop button.
function AudioControls() {
const app = React.useContext(Context)
const audio = Specviz.Audio.useContext()
return (
<>
<button
children="Rewind 5s"
onClick={() => audio.transport.seek(t => t - 5)}
title="z"
/>
{audio.state.pause ? (
<button
children="Play"
onClick={() => audio.transport.play()}
title="x"
/>
) : (
<button
children="Stop"
onClick={() => audio.transport.stop()}
title="x"
/>
)}
<button
children="Forward 5s"
onClick={() => audio.transport.seek(t => t + 5)}
title="c"
/>
</>
)
}
Specviz.Action
Specviz does not include default behaviors or keyboard shortcuts. Instead, your application will decide what tools are needed, and when they trigger a particular Specviz action.
We will expand our Visualizer used in Basic Audio to add annotate, select, move and seek. actions. In general, use Specviz.Action.Provider to assign your actions to Specviz components.
function Visualizer() {
...
return (
<...>
<Specviz.Action.Provider
onClick={select}
onContextMenu={seek}
onDrag={move}
onRect={annotate}
>
<Specviz.Visualization .../>
</Specviz.Action.Provider>
</...>
)
}
Let's start writing our select, move and seek actions now.
function Visualizer() {
const audio = Specviz.Audio.useContext()
const note = Specviz.Note.useContext()
const viewport = Specviz.Viewport.useContext()
const select: Specviz.Action.Handler["onClick"] = React.useCallback(
({ unit, rel, abs, xaxis, yaxis, event }) => {
note.selectPoint(abs, Specviz.Note.selectionMode(event))
},
[note.selectPoint],
)
const move: Specviz.Action.Handler["onDrag"] = React.useCallback(
({ dx, dy, event }) => {
if (note.selection.size > 0)
note.moveSelection(
dx / viewport.state.zoom.x,
dy / viewport.state.zoom.y,
)
},
[note.moveSelection, note.selection, viewport.state.zoom],
)
const seek: Specviz.Action.Handler["onContextMenu"] = React.useCallback(
({ unit, rel, abs, xaxis, yaxis, event }) => {
audio.transport.seek(unit.x)
},
[audio.transport.seek],
)
...
}
To write the annotate action, we will insert our custom label found in our application context. The annotation's required id field can be generated using any generator or sequence.
function Visualizer() {
const app = React.useContext(Context)
const note = Specviz.Note.useContext()
...
const annotate: Specviz.Action.Handler["onRect"] = React.useCallback(
({ unit, rel, abs, xaxis, yaxis, event }) => {
if (note.selection.size == 0)
note.create({
...unit,
id: crypto.randomUUID(),
xunit: xaxis.unit,
yunit: yaxis.unit,
properties: { label: app.label },
})
},
[app.label, note.create, note.selection],
)
...
}
Specviz.Note.useContext
Use Specviz.Note.useContext<T> with your UserData to access your note context.
function AnnotationTable() {
const note = Specviz.Note.useContext<UserData>()
return (
<table>
<thead>
<tr>
<th />
<th>Id</th>
<th>Label</th>
<th>Hz (min)</th>
<th>Hz (max)</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{Array.from(note.regions.values(), region => (
...
))}
</tbody>
</table>
)
}
Use other functions like note.setSelection and note.updateRegionProperties to complete the table.
function AnnotationTable() {
const note = Specviz.Note.useContext<UserData>()
return (
<table>
<thead>...</thead>
<tbody>
{Array.from(note.regions.values(), region => (
<tr key={region.id}>
<td>
<input
type="checkbox"
checked={note.selection.has(region.id)}
onChange={event =>
note.setSelection(prev => {
const next = new Set(prev)
if (event.target.checked) next.add(region.id)
else next.delete(region.id)
return next
})
}
/>
</td>
<td>{region.id}</td>
<td>
<select
value={region.properties?.label ?? "🤷🏽"}
onChange={event =>
note.updateRegionProperties(region.id, p => ({
...p,
label: event.target.value as Label,
}))
}
style={{ fontSize: "1rem" }}
>
<option value="🤷🏽" children="🤷🏽" />
{Object.values(Label).map(label => (
<option key={label} value={label} children={label} />
))}
</select>
</td>
<td>{Format.hz(region.y)}</td>
<td>{Format.hz(region.y + region.height)}</td>
<td>{Format.timestamp(region.width)}</td>
</tr>
))}
</tbody>
</table>
)