Metadata-Version: 2.4
Name: wavnote
Version: 0.1.0
Summary: Printable multitrack waveform illustrations with timed annotations
Keywords: waveform,svg,cli,daw,murder-mystery
Author: CaseClosed
Author-email: CaseClosed <kontakt@caseclosed.no>
License-Expression: MIT
License-File: LICENSE
Requires-Dist: typer>=0.16,<1
Requires-Dist: pydantic>=2.10,<3
Requires-Dist: pyyaml>=6,<7
Requires-Dist: numpy>=2,<3
Requires-Dist: fonttools>=4.58,<5
Requires-Dist: resvg-py>=0.5,<1 ; extra == 'png'
Requires-Python: >=3.11
Project-URL: Homepage, https://github.com/caseclosed-no/wavnote
Project-URL: Repository, https://github.com/caseclosed-no/wavnote
Project-URL: Issues, https://github.com/caseclosed-no/wavnote/issues
Provides-Extra: png
Description-Content-Type: text/markdown

# Wavnote

A Python CLI for making multitrack waveform illustrations as SVG or PNG. Describe
speech, timed text, sound effects, and background noise in a YAML or JSON file;
Wavnote draws synchronized channels in a dark or light DAW interface, or a
minimal layout. Useful for printed evidence, fictional recordings, and props.

Runs offline. No DAW, audio device, browser, font download, or external speech
service is required.

## Proudly sponsored by CaseClosed

<a href="https://caseclosed.no"><img src="docs/assets/caseclosed-logo.png" alt="CaseClosed" width="240"></a>

Proudly sponsored by [CaseClosed](https://caseclosed.no) in Norway and
[Caseclosed Mysteries](https://caseclosedmysteries.com) internationally,
creators of printed murder mystery games.

## Get started

You'll need Python 3.11 or newer and [uv](https://docs.astral.sh/uv/).
Run it without a permanent installation:

```sh
uvx wavnote init -o session.yaml
uvx wavnote render session.yaml -o recording.svg
uvx --from 'wavnote[png]' wavnote render session.yaml -o recording.png --dpi 300
```

`uvx` installs the tool's dependencies in its own isolated environment. For PNG
exports, use `--from 'wavnote[png]'` to include the rasterizer. For a permanent
installation with both formats, use `uv tool install 'wavnote[png]'`.

To run from a source checkout, use `uvx --from . wavnote ...` for SVG, or
`uvx --from '.[png]' wavnote ...` for PNG. No `uv sync` step is needed to run the
CLI this way. Pin a published version with `uvx --from 'wavnote[png]==0.1.0' wavnote ...`.

`init` writes a starter file and refuses to overwrite an existing file. Edit
that file to change the channels, speech, effects, volumes, and timing.

## A few examples

These are actual generator outputs. The three themes use the same
[session.yaml](examples/session.yaml); only the presentation changes. SVGs are
included alongside the PNG previews so you can inspect or print them.

### DAW dark

A dark audio-editor interface with transport controls, channel panels, a shared
clock ruler, and optional timed annotations.

[Open SVG](examples/daw_dark.svg)

![Dark DAW waveform example](examples/daw_dark.png)

### DAW light

The same audio-editor interface in a light theme.

[Open SVG](examples/daw_light.svg)

![Light DAW waveform example](examples/daw_light.png)

### Minimal

Waveforms, channel labels, ruler, and annotations, suitable for placing inside
an existing document.

[Open SVG](examples/minimal.svg)

![Minimal waveform example](examples/minimal.png)

### Recording gap

Unavailable data is visibly different from recorded silence. It keeps its place
on the common timeline.

[Session](examples/gap.yaml) · [SVG](examples/gap.svg)

![Recording gap example](examples/gap.png)

### Wrapped timeline

Each channel wraps into two consecutive lines on the same page: K1, K1, K2,
K2, K3, K3. Matching lines retain identical time ranges across every channel.
This portrait example also uses a custom application title.

[Session](examples/wrapped.yaml) · [SVG](examples/wrapped.svg)

![Wrapped multitrack example](examples/wrapped.png)

Recreate the previews:

```sh
uvx --from '.[png]' --with PyYAML python examples/render_examples.py
```

The landscape previews are 1400 pixels wide; the wrapped portrait preview is
1100 pixels wide. SVGs retain their A4 physical sizes; default landscape PNG
exports at 300 DPI are 3508 × 2480 pixels. No fonts are
bundled. These checked-in examples use the installed Menlo interface font;
your local system fallback may look different.

## Session files

YAML and JSON use the same versioned schema. Here is a small complete session:

```yaml
schema_version: 1
session:
  title: recording_01
  start: "2026-01-01T22:32:00+01:00"
  duration: 15
  sample_rate: 48000
  seed: 42
render:
  mode: daw_dark
  title: Wavnote
  page: a4-landscape
  timestamps: clock
  annotations: words
voices:
  voice_a:
    delivery: quiet
    gain_db: -2
    pitch_hz: 180
    breathiness: 0.2
channels:
  - id: K1
    ambience: {preset: room-tone, level_dbfs: -45}
    events:
      - id: phrase_01
        type: speech
        voice: voice_a
        at: 2
        duration: 4
        words:
          - {text: "Wait", start: 2, end: 2.5}
          - {text: "here.", start: 3, end: 3.8}
  - id: K2
    events:
      - id: knock_01
        type: sfx
        preset: hard-knock
        at: 9.5
        duration: 0.3
        gain_db: -6
```

All `at`, `start`, and `end` values are **elapsed seconds from the session
start**, including words. The datetime needs a timezone offset. Clock labels
are computed from that start and work across midnight. Source recordings and
fonts in a session file resolve relative to that file.

Give events stable IDs: their variation is seeded by the session seed and event
ID. Changing a style, page size, or crop does not regenerate the signal. If you
omit IDs, Wavnote creates them from the channel and event position; inserting
an earlier event then changes those automatically assigned IDs.

Unknown keys, invalid intervals, unknown voices/effects, duplicate source event
IDs, and events overlapping recording gaps are rejected with validation errors.
Events may overlap each other, including simultaneous speech.

## Timed text below the waveform

There is no forced alignment or speech recognition. Annotations are authored
text positioned immediately below their channel's waveform.

For precise control, supply `words` with explicit `start`/`end` values. A word
entry can also contain a short phrase. Its anchor is the supplied start and a
small line shows its duration. The speech-shaped waveform follows those
intervals, preserving your pauses.

For quick authoring, use `text` instead of `words`:

```yaml
- id: phrase_02
  type: speech
  at: 5
  duration: 6
  text: "Did you hear that? Stay here."
```

Wavnote distributes the text over that duration using token lengths and
punctuation. These positions are visual authoring conveniences, not measured
speech timings. Explicit timings override this convenience by using `words`
instead of `text`.

You can omit text entirely, or hide it using `--annotations none`. Sound effects
have no automatic explanatory labels. Voices identify signal profiles in the
input; the export shows channel names and your supplied annotations.

When words would collide, additional annotation rows preserve their time
anchors. If the annotations cannot fit legibly, Wavnote reports an error.
Shorten the window, enlarge the page, or export multiple pages; it does not
silently shrink or drop words. Labels at crop boundaries retain their anchors
and produce a diagnostic warning.

## Sounds and volume

Speech is synthesized into a **nonverbal speech-shaped signal**. This tool
makes illustrations, not playable spoken dialogue. Harmonics, noisy consonant
sections, syllable envelopes, irregular modulation, and pauses create the
waveform. Use a real WAV source if you need the illustration to represent
actual recorded audio.

List the procedural sound library with `uvx wavnote presets`:

- **Background:** `room-tone`, `ventilation`, `electrical-hum`, `tape-hiss`.
- **Soft sounds:** `breath`, `fabric-rustle`, `soft-slide`.
- **Impacts:** `dull-thud`, `hard-knock`, `object-drop`, `distant-impact`.
- **Doors and panels:** `door-latch`, `door-close`, `hinge-creak`, `metal-click`,
  `panel-rattle`.
- **Movement:** `scrape`, `footsteps`.

Effects differ in attack, decay, texture, and repetition; the drawing comes
from signal peaks rather than arbitrary decorative bars. Overlapping events
are mixed before extracting peaks, and short transients survive zooming out.

```yaml
- id: steps_01
  type: sfx
  preset: footsteps
  at: 10
  duration: 3
  count: 5
  material: concrete
  gain_db: -4
  decay: 1.2
```

`material` accepts `wood`, `metal`, `fabric`, or `concrete`, changing the
resonance of footsteps, knocks, and drops. `count` controls footsteps and
panel rattles; `decay` scales the decay of impact recipes. They are
preset-specific controls, not universal effects on every sound.

Set `gain_db` independently on an event, voice, or channel. Gains add in dB;
-6 dB roughly halves amplitude. A voice's `delivery` is `quiet`, `normal`,
`whisper`, or `raised`. `pitch_hz` and `breathiness` affect its signal profile,
not typography. Channel ambience has a separate `level_dbfs` control.

Every channel shares the same amplitude scale. Channels are not individually
normalized: quiet sounds stay quiet. Signals over 0 dBFS produce warnings and
are clipped visually at the lane bounds. `render.display_gain_db` adjusts the
shared display scale without changing the signal.

Channel `acoustics` accepts `dry`, `room`, `behind-door`, `corridor`, and
`shaft`. These add simple attenuation, filtering, and reflection tails. They
are illustrative approximations rather than measurements of a real room.

### Sound bleed across microphones

Reference an existing event to hear the same source through another channel's
acoustics, with independent gain and delay:

```yaml
- id: bleed_01
  ref: phrase_01
  type: speech
  at: 2
  duration: 4
  gain_db: -12
  delay: 0.012
```

The referenced event provides the signal, voice, and source audio; `at` is its
new start and `delay` is an additional propagation delay. Its duration must
match the source. References point to original events, not other references.
No transcript is copied to the receiving channel automatically. You can add
explicit words there if desired. An SFX reference should repeat the source's
`type: sfx` and `preset` fields.

### Import a WAV recording

```yaml
- id: actual_audio
  type: audio
  source: ./audio/channel1.wav
  source_offset: 12
  at: 3
  duration: 8
  gain_db: -4
  words:
    - {text: "Wait.", start: 3.2, end: 3.7}
```

Imports support uncompressed 8/16/24/32-bit integer PCM WAV. Multichannel
sources are averaged to mono and resampled to the session rate. The source
must contain the requested duration. Float WAV and compressed audio need
conversion first. Optional annotations remain manually supplied session times.

### Recording gaps

```yaml
gaps:
  - {start: 12, end: 15}
```

Add `gaps` at channel level. They draw a hatched missing-data region while
preserving elapsed time. Recorded quiet still includes its configured ambience;
a gap has no samples. Events cannot occupy a gap. Use separate crop windows
for separate excerpts.

## Fonts

Choose one interface font for the whole layout, not separate fonts for voices.
By default Wavnote looks for an installed monospace interface font, then a
sans-serif fallback. Nothing is bundled or downloaded.

```sh
uvx wavnote render session.yaml --font ./fonts/interface.ttf -o custom.svg
uvx wavnote render session.yaml --font-family sans-serif -o sans.svg
```

TTF, OTF, and the first face of a TTC collection are supported. A session file
can set `render.font`; a CLI font path resolves from your working directory.
If there is no suitable installed font, provide one with `--font`.

SVG text is outlined by default, so readers do not need the font installed and
PNG exports preserve its placement. Use `--editable-text` for actual SVG text;
that output depends on the recipient having the font installed. The selected
font must contain the characters you use. Complex-script shaping, emoji, and
variable-font axis selection are not supported.

## Sizes, windows, and exports

All modes default to A4 landscape (297 × 210 mm). Other `--page` choices:
`a4-portrait`, `a3-landscape`, and `letter` (US Letter portrait). Custom
`--width` and `--height` accept mm, cm, in, pt, or px; a bare number means px.
SVG dimensions remain physical; DPI applies only to PNG.

```sh
uvx wavnote render session.yaml --mode daw_light -o light.svg
uvx wavnote render session.yaml --title "Studio / session 03" -o custom-title.svg
uvx wavnote render session.yaml --width 24cm --height 16cm -o custom.svg
uvx wavnote render session.yaml --window 10:25 -o excerpt.svg
uvx wavnote render session.yaml --annotations none --timestamps none -o plain.svg
uvx --from 'wavnote[png]' wavnote render session.yaml -o print.png --dpi 300
uvx --from 'wavnote[png]' wavnote render session.yaml -o preview.png --pixel-width 1400
uvx wavnote render session.yaml --paginate 15 --output-dir pages
uvx wavnote render session.yaml -o - > recording.svg
```

PNG resolution can be controlled by DPI or explicit pixel width/height. One
pixel dimension preserves the page aspect ratio; supplying both must preserve
it too. PNG export requires the `png` extra (`uvx --from 'wavnote[png]' wavnote ...`),
which uses resvg and does not need a browser. PNG pixel dimensions determine print resolution; SVG retains the
explicit physical size. Print at actual size / 100%.

The export modes are `daw_dark` (default), `daw_light`, and `minimal`. Both DAW
modes show the same controls; only the palette changes. Their header reads
`48 kHz MULTITRACK` at the default sample rate. There is no redundant time-range
or channel-count footer.

Use `--title "Studio / session 03"` or `render.title` to replace the default
`Wavnote` application header. `session.title` remains the recording name shown
beneath it. Minimal mode omits the application header.

`--timestamps` accepts `clock`, `elapsed`, and `none`. `--annotations` accepts
`words` and `none`. `render.playhead` adds a cursor at an elapsed second.

A window uses **elapsed seconds**, such as `--window 10:25`, not clock text.
`--paginate 15` writes `recording-001.svg`, `recording-002.svg`, etc. Combine it
with `--window` to paginate only an excerpt. Change the `-o` suffix or use
`--format png` for PNG pages.

### Wrap a timeline on the same page

```sh
uvx wavnote render session.yaml --rows 2 --page a4-portrait -o wrapped.svg
uvx wavnote render session.yaml --wrap 15 --page a4-portrait -o wrapped.svg
```

`--rows 2` divides the selected recording window into two equal segments.
`--wrap 15` starts a new segment every 15 seconds; the last segment can be
shorter. All segments keep the same horizontal time scale; a shorter final
segment leaves unused space instead of stretching its sounds. Set `render.rows` or `render.wrap` in YAML/JSON for the same behavior.
Use one or the other. All segments of K1 appear consecutively, followed by all
segments of K2, and so on. Each channel uses the same split points and time
scale. Times and sound positions remain session-relative throughout.

Wrapping keeps everything in one SVG/PNG. `--paginate` creates separate files;
you can combine pagination and wrapping, for example `--paginate 60 --rows 2`
for two 30-second segments per page. Each segment has its own ruler. Annotations
and gaps crossing a wrap boundary are clipped to each segment without changing
their original times.

More segments need more vertical space. For three channels split into two
segments, the included example uses A4 portrait. Increase `--height`, choose a
larger page, or paginate if the waveforms and annotations cannot fit legibly.

Limits: 32 channels, a 24-hour session, sample rates 8–96 kHz, at most 32
timeline segments and 120 seconds per segment, 40–1500 mm page dimensions, and
PNGs up to 64 megapixels (64–16384 pixels per edge). Longer sessions need
wrapping, cropping, or pagination. Dense speech benefits from shorter windows.

```sh
uvx wavnote validate session.yaml
uvx wavnote render --help
```

Validation checks schema, source recordings, font glyphs, clipping, and the
selected layout. Errors and progress go to stderr; SVG stdout stays clean.
Internal voice IDs, effect names, acoustic settings, and asset paths are not
embedded in the exported illustration. Only your public title, labels,
application title, and annotations appear.

## Using it from Python

```python
from pathlib import Path
from wavnote import load_session, render_session, png_bytes

path = Path("session.yaml")
session = load_session(path)
result = render_session(session, base_dir=path.parent)
Path("recording.svg").write_text(result.svg, encoding="utf-8")
Path("recording.png").write_bytes(png_bytes(result, dpi=300))
```

The CLI and Python API share the same schema, signal engine, and renderer.
For reproducible exports, use explicit event IDs, a fixed seed, the same
Wavnote/dependency versions, and an explicitly supplied font.

## Development and releases

```sh
uvx --with '.[png]' --with pillow pytest
uvx ruff check src tests examples
uvx ruff format --check src tests examples
uv build --no-sources
```

Development commands above run from a checkout. The `--with '.[png]'` option installs the project and its runtime dependencies;
`--with pillow` adds image checks to the isolated pytest environment. The pytest
configuration points imports at the local `src` package. Ruff only needs its own
tool environment. `uv build` builds distributions rather than running a tool.

The package uses `uv_build` and a console entry point. Tests cover timing
anchors, crop stability, relative gain, brief transients, gaps, WAV import,
font selection, pagination, synchronized wrapping, header customization, privacy of exported
metadata, and PNG dimensions.
GitHub Actions checks Linux, macOS, and Windows, plus installed-wheel execution.

To release, update the version in `pyproject.toml` and `src/wavnote/__init__.py`,
configure this repository as a PyPI Trusted Publisher for the `release.yml`
workflow and `pypi` environment, then push a matching `v0.1.0` tag. The workflow
checks that the tag matches the package version before building and publishing.

The code is MIT licensed. User-supplied fonts and audio retain their own
licenses; no font or audio recording is redistributed by this package. The
CaseClosed logo in the documentation remains CaseClosed branding.
