Compare commits

...

9 Commits
v0.1.0 ... main

Author SHA1 Message Date
Peter Steinberger
507790169d
docs: fix Quickstart button contrast and clipped descenders in hero
Some checks failed
ci / test (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
Use a saturated violet (#5938e5) for the primary button background in
both themes so white text reads cleanly — previously dark mode used
the lighter --accent (#a48bff), giving low contrast against white.

Also raise the gradient h1 line-height from 1.04 to 1.18 and add
padding-bottom: .08em so the gradient text-clip stops chopping the
descenders on letters like g/y/p.
2026-05-08 16:44:35 +01:00
Peter Steinberger
0277e3e7bf
docs: build songsee.sh with the gogcli-style site generator
Port gogcli's custom static-site builder (scripts/build-docs-site.mjs +
scripts/docs-site-assets.mjs) and adapt branding to songsee — spectral
gradient hero, viz-mode pill row, JetBrains Mono code blocks, sidebar
nav with search, dark/light theme toggle, per-page TOC, and link
validation at build time.

Wire it up via 'make docs-site' and a pages.yml workflow that uploads
dist/docs-site as the Pages artifact. GitHub Pages source is now
build_type=workflow (was legacy/Jekyll).
2026-05-08 16:35:54 +01:00
Peter Steinberger
11680218e7
ci: update Homebrew tap after releases 2026-05-08 16:31:07 +01:00
Peter Steinberger
5cee6499fe
docs: rewrite site gogcli-style with per-feature pages
Drop the custom Jekyll layout, CSS, and JS in favor of GitHub Pages'
default theme — same approach gogcli.sh uses. Replace the marketing
landing page with a plain-markdown overview that mirrors gogcli's
"try it / what it does / pick your path" structure.

Add one focused page per feature: install, quickstart, visualizations,
palettes, decoding, rendering, pipeline (spec), and CLI reference.
Verify ffmpeg pipeline (f32le) and decoder coverage against the actual
audio package.
2026-05-08 16:01:45 +01:00
Peter Steinberger
b177ce9d35
docs: point site links to openclaw repo 2026-05-08 15:32:05 +01:00
Peter Steinberger
5c041de39a
docs: note Clawd style update
Some checks failed
ci / test (push) Has been cancelled
2026-05-04 02:05:26 +01:00
Peter Steinberger
97cdef4634 docs: add releasing checklist 2026-01-02 15:31:23 +01:00
Peter Steinberger
6b24d31e99 docs: add clawd to docs site too 🦞
The lobster remembers everything.

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-02 15:14:52 +01:00
Peter Steinberger
f11e6e7e9b docs: Codex forgot about clawd AGAIN 🦞
Adding clawd palette back to feature list and palette section.
The lobster will not be erased from history.

Co-authored-by: Clawd <clawdbot@gmail.com>
2026-01-02 15:14:35 +01:00
22 changed files with 1914 additions and 711 deletions

54
.github/workflows/pages.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "Makefile"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Build docs site
run: make docs-site
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

View File

@ -0,0 +1,64 @@
name: Update Homebrew Tap
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish to the tap (for example, v0.1.0)"
required: true
type: string
permissions:
contents: read
jobs:
update-homebrew-tap:
runs-on: ubuntu-latest
steps:
- name: Resolve release tag
id: release
shell: bash
run: |
set -euo pipefail
tag="${{ inputs.tag || github.ref_name }}"
if [[ -z "$tag" ]]; then
echo "Missing release tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "request_id=songsee-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT"
- name: Dispatch tap update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
shell: bash
run: |
set -euo pipefail
test -n "$GH_TOKEN"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
-f formula=songsee \
-f tag="${{ steps.release.outputs.tag }}" \
-f repository=openclaw/songsee \
-f artifact_url='https://github.com/openclaw/songsee/archive/refs/tags/{tag}.tar.gz' \
-f request_id="${{ steps.release.outputs.request_id }}"
- name: Wait for tap update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
shell: bash
run: |
set -euo pipefail
for _ in {1..20}; do
run_id="$(gh run list --repo steipete/homebrew-tap --workflow update-formula.yml --json databaseId,displayTitle --jq '.[] | select(.displayTitle | contains("${{ steps.release.outputs.request_id }}")) | .databaseId' | head -n1)"
if [[ -n "$run_id" ]]; then
gh run watch "$run_id" --repo steipete/homebrew-tap --exit-status
exit 0
fi
sleep 5
done
echo "Timed out waiting for tap workflow to appear." >&2
exit 1

4
.gitignore vendored
View File

@ -30,3 +30,7 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
# Build output
/bin/
/dist/

View File

@ -1,5 +1,10 @@
# Changelog
## 0.1.1 - Unreleased
- New Clawd style
- Docs: rewritten gogcli-style — plain-markdown pages for install, quickstart, visualizations, palettes, decoding, rendering, pipeline, and CLI; new custom static-site builder (`make docs-site`) and `pages.yml` workflow render songsee.sh with a sidebar nav, search, dark-mode toggle, and per-page TOC
## 0.1.0 - 2026-01-02
- Spectrogram + feature panels (mel, chroma, hpss, selfsim, loudness, tempogram, mfcc, flux) with multi-panel grid

View File

@ -1,5 +1,8 @@
.PHONY: songsee
.PHONY: songsee docs-site
songsee:
mkdir -p bin
go build -o bin/songsee ./cmd/songsee
docs-site:
@node scripts/build-docs-site.mjs

View File

@ -5,7 +5,7 @@
## Features
- **9 visualization modes**: spectrogram, mel, chroma, hpss, selfsim, loudness, tempogram, mfcc, flux
- **5 color palettes**: classic, magma, inferno, viridis, gray
- **6 color palettes**: classic, magma, inferno, viridis, gray, clawd
- **Auto-contrast**: per-panel percentile normalization for readable heatmaps
- **Combine modes**: stack multiple visualizations in one grid image
- **Universal input**: WAV, MP3, or anything ffmpeg can handle
@ -54,7 +54,7 @@ songsee track.mp3 --viz hpss,chroma --style inferno -o viz.png --width 2560 --he
## Palettes
`classic` · `magma` · `inferno` · `viridis` · `gray`
`classic` · `magma` · `inferno` · `viridis` · `gray` · `clawd` 🦞
## Options

22
docs/RELEASING.md Normal file
View File

@ -0,0 +1,22 @@
---
summary: "Release checklist for songsee (GitHub release + Homebrew tap + Pages)"
---
# Releasing songsee
Follow these steps for each release. Title GitHub releases as `songsee <version>`.
## Checklist
- Ensure `CHANGELOG.md` has the new version section.
- Tag the release: `git tag -a v<version> -m "Release <version>"` and push tags after commits.
- Verify tests + lint: `go test ./... -cover` and `golangci-lint run`.
- Build source archive for Homebrew: `git archive --format=tar.gz --output /tmp/songsee-<version>.tar.gz v<version>`.
- Compute checksums: `shasum -a 256 /tmp/songsee-<version>.tar.gz`.
- Update `../homebrew-tap/Formula/songsee.rb` with the new tarball URL + sha256 and version.
- Update tap README to list songsee if needed.
- Commit + push changes in songsee and the tap.
- Create GitHub release for `v<version>`:
- Title: `songsee <version>`
- Body: bullets from `CHANGELOG.md` for that version
- Verify Homebrew install: `brew update && brew reinstall steipete/tap/songsee && songsee --version`.
- Verify Pages build is green (workflow `pages-build-deployment`).

View File

@ -1,11 +0,0 @@
title: songsee
description: Generate modern spectrogram images from audio files.
url: "https://songsee.sh"
baseurl: ""
markdown: kramdown
permalink: pretty
kramdown:
input: GFM
collections: {}

View File

@ -1,58 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title>
<meta name="description" content="{{ page.description | default: site.description }}">
<meta name="theme-color" content="#0d0b14">
<link rel="canonical" href="{{ site.url }}{{ page.url | replace: 'index.html', '' }}">
<meta property="og:site_name" content="{{ site.title }}">
<meta property="og:title" content="{% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %}">
<meta property="og:description" content="{{ page.description | default: site.description }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ site.url }}{{ page.url | replace: 'index.html', '' }}">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400,600,700&family=Manrope:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap">
<link rel="stylesheet" href="{{ '/assets/css/site.css' | relative_url }}">
</head>
<body class="{{ page.body_class | default: 'page' }}">
<div class="grain" aria-hidden="true"></div>
<div class="orb orb-a" aria-hidden="true"></div>
<div class="orb orb-b" aria-hidden="true"></div>
<header class="nav">
<div class="nav-inner">
<a class="logo" href="{{ '/' | relative_url }}">
<span class="logo-mark"></span>
<span class="logo-text">songsee</span>
</a>
<nav class="nav-links">
<a href="{{ '/' | relative_url }}#install">Install</a>
<a href="{{ '/' | relative_url }}#usage">Usage</a>
<a href="{{ '/spec/' | relative_url }}">Spec</a>
<a href="https://github.com/steipete/songsee">GitHub</a>
</nav>
</div>
</header>
<main class="content">
{{ content }}
</main>
<footer class="footer">
<div class="footer-inner">
<div>
<div class="footer-title">songsee</div>
<div class="footer-sub">Spectrograms that feel alive.</div>
</div>
<div class="footer-links">
<a href="https://github.com/steipete/songsee">GitHub</a>
<a href="{{ '/spec/' | relative_url }}">Spec</a>
<a href="https://songsee.sh">songsee.sh</a>
</div>
</div>
</footer>
<script src="{{ '/assets/js/site.js' | relative_url }}" defer></script>
</body>
</html>

View File

@ -1,436 +0,0 @@
:root {
color-scheme: dark;
--bg: #0d0b14;
--bg-soft: #141022;
--bg-deep: #08070f;
--ink: #f4f2ff;
--muted: #b7b1c8;
--accent: #ffb347;
--accent-2: #2cf6f6;
--accent-3: #ff5da2;
--accent-4: #9d7bff;
--card: rgba(23, 18, 38, 0.72);
--stroke: rgba(255, 255, 255, 0.08);
--shadow: 0 20px 60px rgba(7, 6, 12, 0.65);
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: "Manrope", system-ui, -apple-system, sans-serif;
--display: "Fraunces", "Times New Roman", serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: var(--sans);
color: var(--ink);
background: radial-gradient(1200px 800px at 15% 10%, rgba(157, 123, 255, 0.25), transparent 55%),
radial-gradient(800px 600px at 85% 0%, rgba(44, 246, 246, 0.18), transparent 60%),
radial-gradient(900px 700px at 85% 80%, rgba(255, 179, 71, 0.16), transparent 65%),
linear-gradient(160deg, var(--bg) 0%, var(--bg-soft) 55%, var(--bg-deep) 100%);
min-height: 100vh;
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
color: var(--accent-2);
}
.grain {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.2;
mix-blend-mode: soft-light;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140' viewBox='0 0 140 140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.35'/%3E%3C/svg%3E");
}
.orb {
position: fixed;
border-radius: 999px;
filter: blur(10px);
opacity: 0.55;
z-index: 0;
animation: drift 18s ease-in-out infinite alternate;
}
.orb-a {
width: 420px;
height: 420px;
background: radial-gradient(circle at 30% 30%, rgba(255, 93, 162, 0.6), rgba(13, 11, 20, 0));
top: -120px;
left: -80px;
}
.orb-b {
width: 520px;
height: 520px;
background: radial-gradient(circle at 60% 40%, rgba(44, 246, 246, 0.4), rgba(13, 11, 20, 0));
bottom: -220px;
right: -140px;
animation-delay: -6s;
}
@keyframes drift {
from {
transform: translate3d(0, 0, 0) scale(0.95);
}
to {
transform: translate3d(40px, -20px, 0) scale(1.05);
}
}
.nav {
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(14px);
background: rgba(10, 9, 16, 0.75);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.nav-inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
}
.logo {
display: inline-flex;
align-items: center;
gap: 12px;
font-family: var(--display);
font-size: 22px;
}
.logo-mark {
width: 18px;
height: 18px;
border-radius: 6px;
background: conic-gradient(from 120deg, var(--accent), var(--accent-3), var(--accent-2), var(--accent));
box-shadow: 0 0 18px rgba(255, 179, 71, 0.35);
}
.nav-links {
display: flex;
gap: 20px;
font-size: 14px;
color: var(--muted);
}
.content {
position: relative;
z-index: 1;
}
.hero {
max-width: 1100px;
margin: 0 auto;
padding: 90px 24px 60px;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 24px;
}
.hero-copy {
grid-column: 1 / span 7;
}
.hero-title {
font-family: var(--display);
font-size: clamp(42px, 6vw, 82px);
line-height: 0.95;
margin: 0 0 18px;
}
.hero-sub {
font-size: 18px;
color: var(--muted);
max-width: 520px;
line-height: 1.6;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 26px;
}
.btn {
padding: 12px 20px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
font-weight: 600;
letter-spacing: 0.02em;
transition: transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease;
}
.btn.primary {
background: linear-gradient(135deg, rgba(255, 179, 71, 0.95), rgba(255, 93, 162, 0.95));
color: #130d1d;
box-shadow: 0 12px 30px rgba(255, 124, 99, 0.35);
border: none;
}
.btn:hover {
transform: translateY(-2px);
}
.hero-meta {
margin-top: 22px;
font-family: var(--mono);
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
}
.hero-visual {
grid-column: 8 / span 5;
position: relative;
}
.spectral-panel {
height: 420px;
border-radius: 24px;
background: radial-gradient(200px 200px at var(--mx, 70%) var(--my, 30%), rgba(44, 246, 246, 0.25), transparent 70%),
linear-gradient(140deg, rgba(24, 16, 44, 0.9), rgba(12, 9, 22, 0.95));
border: 1px solid var(--stroke);
box-shadow: var(--shadow);
overflow: hidden;
position: relative;
}
.spectral-panel::before {
content: "";
position: absolute;
inset: 30px 22px 90px 22px;
border-radius: 18px;
background: linear-gradient(90deg, #0a0a12, #0a0a12 30%, rgba(255, 179, 71, 0.9), rgba(44, 246, 246, 0.9), rgba(157, 123, 255, 0.9));
filter: saturate(1.4);
animation: sweep 6s ease-in-out infinite alternate;
}
.spectral-panel::after {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08) 1px, transparent 1px, transparent 6px);
mix-blend-mode: screen;
opacity: 0.35;
}
.spectral-caption {
position: absolute;
bottom: 22px;
left: 24px;
font-family: var(--mono);
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
@keyframes sweep {
0% {
transform: translateX(-6%) scaleY(0.95);
}
100% {
transform: translateX(6%) scaleY(1.02);
}
}
.section {
max-width: 1100px;
margin: 0 auto;
padding: 30px 24px 70px;
}
.section-title {
font-family: var(--display);
font-size: clamp(28px, 3vw, 42px);
margin: 0 0 14px;
}
.section-sub {
color: var(--muted);
max-width: 680px;
line-height: 1.6;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 18px;
margin-top: 26px;
}
.card {
background: var(--card);
border: 1px solid var(--stroke);
border-radius: 18px;
padding: 18px;
box-shadow: var(--shadow);
}
.card h3 {
margin: 0 0 8px;
font-size: 18px;
}
.card p {
margin: 0;
color: var(--muted);
line-height: 1.5;
font-size: 14px;
}
.code-block {
background: rgba(8, 8, 14, 0.9);
border-radius: 16px;
padding: 18px 20px;
border: 1px solid rgba(255, 255, 255, 0.08);
font-family: var(--mono);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
}
.kicker {
font-family: var(--mono);
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
.palette-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 18px;
}
.palette {
height: 64px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.palette.classic {
background: linear-gradient(90deg, #000000, #002060, #00a0c8, #ffb400, #ffffff);
}
.palette.magma {
background: linear-gradient(90deg, #000004, #3b0c57, #b4367a, #fb8c3c, #fcfdbf);
}
.palette.inferno {
background: linear-gradient(90deg, #000004, #3d0965, #bb3754, #f98e08, #fcffa4);
}
.palette.viridis {
background: linear-gradient(90deg, #440154, #3a528b, #20908c, #5ec962, #fde725);
}
.palette.gray {
background: linear-gradient(90deg, #000000, #ffffff);
}
.domain-note {
margin-top: 18px;
padding: 12px 16px;
border-radius: 12px;
background: rgba(44, 246, 246, 0.08);
border: 1px solid rgba(44, 246, 246, 0.2);
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
}
.footer {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 30px 24px 40px;
background: rgba(7, 6, 12, 0.65);
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: space-between;
align-items: center;
}
.footer-title {
font-family: var(--display);
font-size: 20px;
}
.footer-sub {
color: var(--muted);
font-size: 13px;
}
.footer-links {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--muted);
}
.reveal {
opacity: 0;
transform: translateY(16px);
animation: rise 0.8s ease forwards;
}
.reveal.delay-1 { animation-delay: 0.1s; }
.reveal.delay-2 { animation-delay: 0.2s; }
.reveal.delay-3 { animation-delay: 0.3s; }
.reveal.delay-4 { animation-delay: 0.4s; }
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
}
.hero-copy,
.hero-visual {
grid-column: auto;
}
.spectral-panel {
height: 320px;
}
.nav-links {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}

View File

@ -1,20 +0,0 @@
(() => {
const panels = document.querySelectorAll('.spectral-panel');
if (!panels.length) return;
const update = (panel, event) => {
const rect = panel.getBoundingClientRect();
const x = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
const y = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1);
panel.style.setProperty('--mx', `${(x * 100).toFixed(1)}%`);
panel.style.setProperty('--my', `${(y * 100).toFixed(1)}%`);
};
panels.forEach((panel) => {
panel.addEventListener('pointermove', (event) => update(panel, event));
panel.addEventListener('pointerleave', () => {
panel.style.removeProperty('--mx');
panel.style.removeProperty('--my');
});
});
})();

93
docs/cli.md Normal file
View File

@ -0,0 +1,93 @@
---
title: CLI
description: "Every songsee flag with its default and accepted values."
---
# CLI
```text
songsee <input> [flags]
```
`<input>` is a file path or `-` for stdin.
## Inputs and output
| flag | type | default | description |
|------|------|---------|-------------|
| `<input>` | string (positional) | required | File path, or `-` to read encoded audio from stdin. |
| `-o`, `--output` | string | input name + extension | Output path. `-` writes the encoded image to stdout. |
| `--format` | `jpg` \| `png` | `jpg` | Output encoder. JPEG is quality 95; PNG is lossless. |
| `--width` | int | `1920` | Output width in pixels. |
| `--height` | int | `1080` | Output height in pixels. |
| `-q`, `--quiet` | bool | `false` | Suppress the stdout output-path echo. |
| `-v`, `--verbose` | bool | `false` | Print decode and slice info to stderr. |
| `--version` | bool | — | Print version and exit. |
## FFT and windowing
| flag | type | default | description |
|------|------|---------|-------------|
| `--window` | int | `2048` | FFT window size in samples. Must be a power of two. |
| `--hop` | int | `512` | Hop size in samples between frames. |
| `--min-freq` | float (Hz) | `0` | Lower bound of the visible frequency band. |
| `--max-freq` | float (Hz) | Nyquist | Upper bound of the visible frequency band. Must exceed `--min-freq`. |
## Slicing
| flag | type | default | description |
|------|------|---------|-------------|
| `--start` | float (s) | `0` | Skip this many seconds from the start of the input. |
| `--duration` | float (s) | `0` (full) | Render only this many seconds after `--start`. |
## Visualization
| flag | type | default | description |
|------|------|---------|-------------|
| `--viz` | repeated string list | `spectrogram` | One or more of: `spectrogram`, `mel`, `chroma`, `hpss`, `selfsim`, `loudness`, `tempogram`, `mfcc`, `flux`. Repeatable or comma-separated. |
| `--style` | string | `classic` | Palette name: `classic`, `magma`, `inferno`, `viridis`, `gray` (alias `grey`), `clawd`. |
## Decoding
| flag | type | default | description |
|------|------|---------|-------------|
| `--sample-rate` | int | `44100` | Sample rate requested from the ffmpeg fallback. Native WAV/MP3 keep the file's rate. |
| `--ffmpeg` | string | first `ffmpeg` on `PATH` | Override the ffmpeg binary used for non-WAV/MP3 inputs. |
## Exit codes
| code | meaning |
|------|---------|
| `0` | Render succeeded. |
| `1` | Decode, render, or write error (message on stderr). |
| `2` | Usage error — bad flag, invalid combination, or missing input. |
## Examples
```bash
# All defaults.
songsee track.mp3
# Mel + chroma in viridis at 2K.
songsee track.mp3 --viz mel,chroma --style viridis --width 2048 --height 1024
# Eight-second slice starting at 12.5s, written to PNG.
songsee track.mp3 --start 12.5 --duration 8 -o slice.png
# Stream from stdin, encode to PNG, write to stdout.
cat track.mp3 | songsee - --format png -o - > spectro.png
# Custom FFT, sub-bass focus.
songsee track.mp3 --window 4096 --hop 1024 --min-freq 20 --max-freq 200
# Pin a specific ffmpeg.
songsee weird.opus --ffmpeg /opt/homebrew/bin/ffmpeg
```
## Related pages
- [Quickstart](quickstart.md) — first render in under a minute.
- [Visualizations](visualizations.md) — when to use each viz mode.
- [Palettes](palettes.md) — palette gradient stops.
- [Decoding](decoding.md) — input formats and ffmpeg fallback.
- [Rendering](rendering.md) — output sizing, format, batch use.

78
docs/decoding.md Normal file
View File

@ -0,0 +1,78 @@
---
title: Decoding
description: "How songsee decodes audio — native WAV/MP3 paths, ffmpeg fallback, sample rate, stdin, and slicing."
---
# Decoding
songsee turns the input into mono `float64` samples before any analysis runs. Two fast paths cover most files; everything else falls through to ffmpeg.
## Inputs
- **File path.** `songsee track.mp3` — any path the OS can open.
- **Stdin.** `songsee -` — reads the encoded stream from stdin. Useful behind `cat`, `curl`, or shell pipelines.
- **Mono mixdown.** Stereo or multichannel inputs are averaged to mono before windowing.
## Native WAV
Pure-Go WAV decoder. Handles:
- PCM 8/16/24/32-bit integer
- 32-bit float, 64-bit float
- WAVE_FORMAT_EXTENSIBLE (with channel masks and sub-format GUIDs)
No external dependency, no ffmpeg roundtrip. The decoder validates the RIFF header and rejects truncated `data` chunks before allocating sample buffers.
## Native MP3
Pure-Go MP3 decoder. Handles MPEG-1/2 Layer III with VBR and CBR. Output sample rate is whatever the file declares; songsee does not resample.
If the decoder hits a malformed frame it surfaces a structured error instead of silently truncating, so corrupt input fails loudly.
## ffmpeg fallback
Anything that isn't WAV or MP3 — FLAC, AAC, M4A, OGG, Opus, video containers, raw streams — is decoded by spawning `ffmpeg`. songsee asks for 32-bit float little-endian mono at the configured sample rate:
```text
ffmpeg -hide_banner -loglevel error \
-i <input> -f f32le -ac 1 -ar <sample-rate> -
```
Tweak the pipeline with:
- `--sample-rate N` — output sample rate fed to ffmpeg (default `44100`).
- `--ffmpeg /path/to/ffmpeg` — override the binary lookup.
If ffmpeg isn't on `PATH` and the file isn't WAV or MP3, songsee fails with a clear error. Install with `brew install ffmpeg` or your distro's package manager.
## Slicing
`--start` and `--duration` slice the decoded audio before analysis. Both are seconds (float). Negative values are rejected.
```bash
songsee long.mp3 --start 60 --duration 15 -o minute1.jpg
songsee long.mp3 --start 60 # 60s to end
songsee long.mp3 --duration 30 # first 30s
```
Slicing happens on samples after decoding; FFT framing then runs on the slice.
## Sample rate notes
- Native WAV/MP3 keep the file's sample rate. `--sample-rate` only affects the ffmpeg pipeline.
- The Nyquist frequency (`sampleRate / 2`) is the upper bound visible in the spectrogram.
- 44.1 kHz / 48 kHz inputs render with the default `--window 2048 --hop 512` at ≈21 ms / frame.
## Verbose decoding
```bash
songsee track.flac --verbose -o out.png
```
`--verbose` prints decode info to stderr — sample count, sample rate, slice bounds — without polluting stdout. Combine with `--quiet` to suppress the trailing output-path echo when piping.
## Related pages
- [Install](install.md) — how to add ffmpeg if you need it.
- [Pipeline](spec.md) — what happens to the samples after decoding.
- [CLI](cli.md) — every flag with its default.

View File

@ -1,112 +1,51 @@
---
layout: default
title: Home
description: Generate modern spectrogram images from audio files with a fast, scriptable CLI.
body_class: home
title: Overview
permalink: /
description: "songsee is a single Go CLI that turns audio into modern spectrogram and feature-panel images — fast WAV/MP3 decode, ffmpeg fallback, nine visualization modes, six palettes."
---
<section class="hero">
<div class="hero-copy">
<div class="kicker reveal delay-1">Spectral imaging CLI</div>
<h1 class="hero-title reveal delay-2">See sound as living color.</h1>
<p class="hero-sub reveal delay-3">
songsee turns audio into precise, high-resolution spectrograms and feature panels. Fast decode
paths for WAV and MP3, ffmpeg fallback for everything else, and palette styles that make science
look cinematic.
</p>
<div class="hero-actions reveal delay-4">
<a class="btn primary" href="#install">Install</a>
<a class="btn" href="https://github.com/steipete/songsee">GitHub</a>
</div>
<div class="hero-meta reveal delay-4">Hann window. Log magnitude. 2048 / 512 defaults.</div>
</div>
<div class="hero-visual">
<div class="spectral-panel" role="img" aria-label="Animated spectrogram preview">
<div class="spectral-caption">Spectrogram preview</div>
</div>
</div>
</section>
## Try it
<section class="section">
<div class="kicker">Why songsee</div>
<h2 class="section-title">A focused pipeline for modern spectrograms.</h2>
<p class="section-sub">
Decode audio into mono samples, window it with Hann, run FFT, and render log-magnitude frames into
a crisp image. The CLI stays small, reliable, and scriptable.
</p>
After [installing](install.md), every render is a one-liner.
<div class="feature-grid">
<div class="card">
<h3>Precise controls</h3>
<p>Window, hop, min/max frequency, output dimensions, and time slicing for exact framing.</p>
</div>
<div class="card">
<h3>Fast decode paths</h3>
<p>Native WAV/MP3 decoding with ffmpeg fallback for everything else.</p>
</div>
<div class="card">
<h3>Palette styles</h3>
<p>classic, magma, inferno, viridis, and gray for a bold spectral aesthetic.</p>
</div>
<div class="card">
<h3>Feature panels</h3>
<p>mel, chroma, hpss, selfsim, loudness, tempogram, mfcc, flux — rendered as single or grid views.</p>
</div>
<div class="card">
<h3>Auto-contrast</h3>
<p>Percentile clamping keeps every panel readable without manual tuning.</p>
</div>
<div class="card">
<h3>Clean output</h3>
<p>JPEG or PNG output, default quality 95, and stable results for batch workflows.</p>
</div>
</div>
</section>
```bash
# Default: a clean spectrogram next to the input file.
songsee track.mp3
<section class="section" id="install">
<div class="kicker">Install</div>
<h2 class="section-title">One command. Instant spectrograms.</h2>
<div class="code-block">
brew install steipete/tap/songsee
go install github.com/steipete/songsee/cmd/songsee@latest
</div>
<div class="domain-note">
songsee.ai, songsee.app, and songsee.dev all redirect to songsee.sh.
</div>
</section>
# Mel spectrogram, magma palette, 2K wide.
songsee track.mp3 --viz mel --style magma --width 2048 --height 1024
<section class="section" id="usage">
<div class="kicker">Usage</div>
<h2 class="section-title">CLI ready for pipes, batches, and automation.</h2>
<div class="code-block">
songsee track.mp3
songsee track.wav --style magma --width 2048 --height 1024 -o spectro.png
cat track.mp3 | songsee - --style gray --format png
songsee track.mp3 --start 12.5 --duration 8 --output slice.jpg
songsee track.mp3 --viz spectrogram,mel,chroma --width 2048 --height 1024
</div>
</section>
# All nine modes in one grid.
songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux
<section class="section">
<div class="kicker">Palettes</div>
<h2 class="section-title">Color maps with character.</h2>
<p class="section-sub">Pick a palette by name for instant visual tone shifts.</p>
<div class="palette-row">
<div class="palette classic" title="classic"></div>
<div class="palette magma" title="magma"></div>
<div class="palette inferno" title="inferno"></div>
<div class="palette viridis" title="viridis"></div>
<div class="palette gray" title="gray"></div>
</div>
</section>
# Slice eight seconds out of a long file.
songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg
<section class="section">
<div class="kicker">Specs</div>
<h2 class="section-title">Detailed pipeline notes.</h2>
<p class="section-sub">
Windowing, bin mapping, normalization, and rendering details live in the spec.
</p>
<div class="hero-actions">
<a class="btn" href="{{ '/spec/' | relative_url }}">Read spec</a>
</div>
</section>
# Pipe from stdin, write PNG to stdout.
cat track.mp3 | songsee - --format png -o - > spectro.png
```
The default output is a 1920×1080 JPEG (quality 95) written next to the input. `--format png` switches encoder, `-o` redirects the path, and `-o -` streams to stdout for piping.
## What songsee does
- **One binary, nine views.** spectrogram, mel, chroma, hpss, selfsim, loudness, tempogram, mfcc, flux — pick one, combine several, or render the full grid.
- **Fast decode paths.** Native Go decoders for WAV (PCM, float, extensible) and MP3; ffmpeg fallback covers everything else.
- **Six palettes.** classic, magma, inferno, viridis, gray, and clawd 🦞 — each tuned for log-magnitude data.
- **Auto-contrast.** Per-panel percentile clamping (0.05 / 0.98) keeps every visualization readable without manual tuning.
- **Scriptable I/O.** File path, stdin (`-`), or stdout. Quiet mode for CI; verbose mode prints decode and slice details to stderr.
- **No Python.** Single static binary. No model files, no virtualenv, no GPU.
## Pick your path
- **Trying it.** [Install](install.md) → [Quickstart](quickstart.md). One brew formula, one command, one image.
- **Picking a view.** [Visualizations](visualizations.md) describes what each of the nine modes shows and when to use it.
- **Picking a palette.** [Palettes](palettes.md) lists the six palettes with their gradient stops.
- **Audio inputs.** [Decoding](decoding.md) covers WAV/MP3 fast paths, ffmpeg fallback, sample rate, and stdin.
- **Output and batches.** [Rendering](rendering.md) explains output sizing, grid layout, format selection, and stdout streaming.
- **Algorithm details.** [Pipeline](spec.md) documents windowing, FFT, bin mapping, and normalization.
- **Flag reference.** [CLI](cli.md) lists every flag with its default.
## Project
Active development; the [changelog](https://github.com/openclaw/songsee/blob/main/CHANGELOG.md) tracks what shipped. Released under the [MIT license](https://github.com/openclaw/songsee/blob/main/LICENSE). Source on [GitHub](https://github.com/openclaw/songsee).

68
docs/install.md Normal file
View File

@ -0,0 +1,68 @@
---
title: Install
description: "Install songsee via Homebrew, go install, or build from source."
---
# Install
`songsee` ships as a single Go binary. Pick whichever delivery mechanism fits.
## Homebrew (macOS, Linux)
```bash
brew install steipete/tap/songsee
songsee --version
```
The formula lives in [`steipete/homebrew-tap`](https://github.com/steipete/homebrew-tap). `brew upgrade songsee` brings in new releases.
## go install
```bash
go install github.com/steipete/songsee/cmd/songsee@latest
songsee --version
```
This builds against the Go version declared in `go.mod`. The binary lands in `$(go env GOBIN)` (or `$(go env GOPATH)/bin`).
## Build from source
```bash
git clone https://github.com/openclaw/songsee.git
cd songsee
make
./songsee --version
```
`make` runs `go build` with the version string injected from `git describe`.
## ffmpeg (optional)
WAV and MP3 decode natively in pure Go. Anything else (FLAC, AAC, OGG, M4A, video containers) falls through to `ffmpeg` on `PATH`.
```bash
brew install ffmpeg # macOS / Linuxbrew
apt install ffmpeg # Debian / Ubuntu
```
Override the lookup with `--ffmpeg /custom/path/ffmpeg` when you have several builds installed.
## Verify the install
```bash
songsee --version
songsee --help
songsee testdata/short.wav # render a tiny known-good file
```
## Updating
- **Homebrew:** `brew upgrade songsee`.
- **go install:** rerun `go install github.com/steipete/songsee/cmd/songsee@latest`.
- **Source:** `git pull && make` — version comes from `git describe`.
## Related pages
- [Quickstart](quickstart.md) — first render in under a minute.
- [Decoding](decoding.md) — WAV/MP3 fast paths and ffmpeg fallback.
- [CLI](cli.md) — every flag with its default.

93
docs/palettes.md Normal file
View File

@ -0,0 +1,93 @@
---
title: Palettes
description: "Six built-in color maps for songsee: classic, magma, inferno, viridis, gray, clawd."
---
# Palettes
`--style` picks a palette. All palettes are 5- or 6-stop linear gradients applied to normalized values in `[0, 1]`. The default is `classic`.
```bash
songsee track.mp3 --style magma
songsee track.mp3 --viz mel --style viridis
songsee track.mp3 --viz hpss,chroma --style clawd
```
Unknown names error out before decoding. All palettes are deterministic — the same input always produces the same colors.
## classic
The default. A black → navy → cyan → amber → white sweep tuned for log-magnitude data, with strong perceptual contrast across the full range.
| stop | color |
|------|-------|
| 0.00 | `#000000` |
| 0.20 | `#002060` |
| 0.45 | `#00a0c8` |
| 0.70 | `#ffb400` |
| 1.00 | `#ffffff` |
## magma
Matplotlib's magma. Black → deep purple → magenta → orange → cream. Smooth and perceptually uniform; works well for everything from spectrograms to MFCCs.
| stop | color |
|------|-------|
| 0.00 | `#000004` |
| 0.25 | `#3b0c57` |
| 0.50 | `#b4367a` |
| 0.75 | `#fb8c3c` |
| 1.00 | `#fcfdbf` |
## inferno
Matplotlib's inferno. Same shape as magma with hotter highs — black → indigo → red → orange → pale yellow.
| stop | color |
|------|-------|
| 0.00 | `#000004` |
| 0.25 | `#3d0965` |
| 0.50 | `#bb3754` |
| 0.75 | `#f98e08` |
| 1.00 | `#fcffa4` |
## viridis
Matplotlib's viridis. Purple → blue → teal → green → yellow. Colorblind-safe and perceptually uniform; the safest choice for publication figures.
| stop | color |
|------|-------|
| 0.00 | `#440154` |
| 0.25 | `#3a528b` |
| 0.50 | `#20908c` |
| 0.75 | `#5ec962` |
| 1.00 | `#fde725` |
## gray
A straight black-to-white linear ramp. Ideal for print, monochrome compositing, or downstream processing that doesn't want hue information.
| stop | color |
|------|-------|
| 0.00 | `#000000` |
| 1.00 | `#ffffff` |
`grey` is accepted as an alias.
## clawd 🦞
The mascot palette. Abyssal navy → ocean teal → coral → lobster red → foam highlight. Six stops, designed to be unmistakable.
| stop | color |
|------|-------|
| 0.00 | `#02040f` |
| 0.20 | `#0b264a` |
| 0.40 | `#126175` |
| 0.60 | `#c1625c` |
| 0.80 | `#cd3728` |
| 1.00 | `#ffe6d2` |
## Related pages
- [Visualizations](visualizations.md) — the nine viz modes that consume these palettes.
- [Pipeline](spec.md) — how values are normalized before palette mapping.

74
docs/quickstart.md Normal file
View File

@ -0,0 +1,74 @@
---
title: Quickstart
description: "From a clean machine to a 1920×1080 spectrogram in under a minute."
---
# Quickstart
One install, one command, one image.
## 1. Install
```bash
brew install steipete/tap/songsee
songsee --version
```
Other paths (go install, source builds, ffmpeg) live on [Install](install.md).
## 2. Render a default spectrogram
```bash
songsee track.mp3
```
Output is a 1920×1080 JPEG (quality 95) written next to the input — `track.jpg`. The file path is echoed to stdout; everything else (decode info under `--verbose`, warnings, errors) goes to stderr so pipes stay clean.
## 3. Pick a different view
```bash
# Mel-scaled spectrogram (perceptual frequency).
songsee track.mp3 --viz mel
# Chromagram (12-bin pitch class) with the magma palette.
songsee track.mp3 --viz chroma --style magma
# Combine harmonic/percussive split with chroma in a single grid.
songsee track.mp3 --viz hpss,chroma --style inferno
```
The full list of views lives on [Visualizations](visualizations.md); palettes are documented on [Palettes](palettes.md).
## 4. Slice a section
```bash
songsee track.mp3 --start 12.5 --duration 8 -o slice.jpg
```
`--start` and `--duration` are seconds. `-o` overrides the default output path; pass `-o -` to write the encoded image to stdout.
## 5. Stream from stdin
```bash
cat track.mp3 | songsee - --format png -o - > spectro.png
```
`-` as the input reads from stdin; `--format png` switches the encoder; `-o -` writes the encoded image to stdout. Combine with `find`, `xargs`, or shell loops for batch rendering.
## 6. Tune dimensions and FFT
```bash
songsee track.mp3 \
--width 2560 --height 1440 \
--window 4096 --hop 1024 \
--min-freq 50 --max-freq 8000
```
`--window` must be a power of two. Larger windows trade time resolution for frequency resolution. `--min-freq` / `--max-freq` clamp the visible frequency band; the default upper bound is the Nyquist frequency.
## Where next
- [Visualizations](visualizations.md) — what each of the nine modes shows.
- [Palettes](palettes.md) — gradient stops for the six built-in palettes.
- [Pipeline](spec.md) — windowing, FFT, bin mapping, normalization.
- [CLI](cli.md) — every flag with its default.

110
docs/rendering.md Normal file
View File

@ -0,0 +1,110 @@
---
title: Rendering
description: "How songsee turns spectrogram data into images — output sizing, format, grid layout, stdout streaming, and batch use."
---
# Rendering
The render stage maps numeric spectrogram and feature data onto pixels, applies the chosen palette, composes panels into a grid, and encodes the result to JPEG or PNG.
## Output path
```bash
songsee track.mp3 # writes track.jpg next to the input
songsee track.mp3 -o out.png # explicit path; format inferred from extension
songsee track.mp3 -o spectro # no extension; appends ".jpg" by default
songsee - -o - # stdin in, encoded image to stdout
```
If `--format` is set explicitly, it overrides extension-based inference. If `-o` already ends in `.png`, `.jpg`, or `.jpeg`, the encoder follows the extension regardless of `--format`.
When the input is `-` (stdin) and no `-o` is given, the output filename is `songsee.jpg` (or `.png`) in the current directory.
## Format
```bash
songsee track.mp3 --format png # PNG, lossless
songsee track.mp3 --format jpg # JPEG, quality 95 (default)
```
JPEG quality is fixed at 95 — high enough that compression artifacts disappear at typical viewing sizes while keeping file sizes reasonable. PNG is the right choice for archival, transparency, or downstream processing that doesn't tolerate JPEG quantization.
## Dimensions
```bash
songsee track.mp3 --width 2560 --height 1440
songsee track.mp3 --width 3840 --height 2160 # 4K
songsee track.mp3 --width 800 --height 200 # banner strip
```
Defaults are 1920×1080. Both must be positive. With multiple visualizations, songsee divides the canvas into a grid; very small canvases with many panels can leave cells too small to render and produce an error.
## Grid layout
When `--viz` selects more than one mode, panels are tiled into a `ceil(sqrt(n))`-column grid with an 8 px gap, sized to fit `--width` × `--height` exactly:
| panels | grid |
|--------|------|
| 1 | 1×1 |
| 2 | 2×1 |
| 3, 4 | 2×2 |
| 56 | 3×2 |
| 79 | 3×3 |
Cells are equal width and height; the canvas is filled top-down, left-right, in the order panels appear in `--viz`.
## Frequency range
`--min-freq` and `--max-freq` clamp the visible band (Hz) for spectrogram, mel, and MFCC panels. The default upper bound is the Nyquist frequency (`sampleRate / 2`).
```bash
# Vocal range, 80 Hz 4 kHz.
songsee track.mp3 --min-freq 80 --max-freq 4000
# Sub-bass focus.
songsee track.mp3 --min-freq 20 --max-freq 200
```
`--max-freq` must be greater than `--min-freq`; songsee rejects the run with exit code 2 otherwise.
## Auto-contrast
Every panel runs an independent percentile clamp on its values before palette mapping (typically 0.05 / 0.98 — ~5% black floor, ~2% white ceiling). This keeps a quiet ambient track and a loud rock track equally readable in the same grid; it also means absolute brightness is not comparable across panels.
The base spectrogram converts magnitudes to decibels (`20·log10(mag + 1e-9)`) before normalizing.
## Stdout streaming
Pass `-o -` to write the encoded image bytes to stdout. Combine with `--quiet` to silence the trailing path echo:
```bash
songsee track.mp3 -o - --quiet > spectro.jpg
songsee track.mp3 -o - --format png --quiet | imgcat
ssh host "songsee /audio/x.flac -o -" > x.jpg
```
Verbose decode info still goes to stderr under `--verbose`, so it doesn't corrupt the binary stream on stdout.
## Batch usage
There's no built-in `songsee batch`; lean on the shell.
```bash
# All MP3s in a directory.
for f in *.mp3; do songsee "$f" --style magma; done
# Parallel via xargs.
ls *.mp3 | xargs -P 8 -I{} songsee {} --width 1920 --height 540
# find + GNU parallel.
find . -name '*.flac' -print0 | parallel -0 songsee {} --style viridis -o {.}.png
```
songsee is single-threaded internally, so parallelism comes from running multiple processes.
## Related pages
- [Visualizations](visualizations.md) — what each panel shows.
- [Palettes](palettes.md) — color maps applied at render time.
- [Pipeline](spec.md) — windowing, FFT, normalization details.
- [CLI](cli.md) — every flag with its default.

View File

@ -1,89 +1,86 @@
---
layout: default
title: Spec
description: songsee spectral pipeline, defaults, and rendering details.
title: Pipeline
description: "songsee's spectral pipeline — decode, window, FFT, bin mapping, normalization, render."
---
<section class="section">
<div class="kicker">Spec</div>
<h1 class="section-title">songsee spectral pipeline</h1>
<p class="section-sub">
This page captures the core algorithm and defaults used by songsee for repeatable, high quality
spectrogram images.
</p>
</section>
# Pipeline
<section class="section">
<h2 class="section-title">Decode</h2>
<div class="card">
<p>
WAV and MP3 decode natively. Any other format falls back to ffmpeg. Input can be a file path or
stdin ("-"). Default sample rate for ffmpeg output is 44100 Hz.
</p>
</div>
</section>
This page documents the algorithm and the defaults songsee uses to produce repeatable, high-quality images. It complements [Visualizations](visualizations.md) (what each mode shows) and [Rendering](rendering.md) (how the canvas gets composed).
<section class="section">
<h2 class="section-title">Spectrogram</h2>
<div class="card">
<p>
Windowed frames use a Hann window. FFT runs on each frame and the magnitude is converted to
decibels using 20 * log10(mag + 1e-9). The default window size is 2048 samples with a hop size
of 512 samples.
</p>
<p>
Frames are computed as 1 + (len(samples) - window + hop - 1) / hop, and bins are window/2 + 1.
Bin spacing is sampleRate / windowSize.
</p>
</div>
</section>
## Stages
<section class="section">
<h2 class="section-title">Rendering</h2>
<div class="card">
<p>
Each output pixel maps to a time frame and frequency bin. Values are normalized by the global
min/max in the computed spectrogram unless clamp values are provided. Feature panels use
percentile-based clamping to preserve contrast across different visualizations. Frequency
range can be restricted via min/max frequency in Hz.
</p>
<p>
Output size defaults to 1920x1080. JPEG quality is 95. PNG output is available via --format.
</p>
</div>
</section>
```text
input → decode → mono mixdown → optional slice → window → FFT
per-mode features (mel, chroma, mfcc, hpss, …)
percentile normalize → palette map → grid compose → encode
```
<section class="section">
<h2 class="section-title">Palettes</h2>
<div class="card">
<p>
Palettes map normalized values to RGBA colors. Available names: classic, magma, inferno,
viridis, gray.
</p>
</div>
</section>
Every stage is deterministic. The same input file with the same flags always produces the same output bytes.
<section class="section">
<h2 class="section-title">Visualizations</h2>
<div class="card">
<p>
Visualizations are selectable via --viz. Defaults to spectrogram. Supported names: spectrogram,
mel, chroma, hpss, selfsim, loudness, tempogram, mfcc, flux. Multiple entries render as a grid
of panels.
</p>
</div>
</section>
## Decode
<section class="section">
<h2 class="section-title">CLI defaults</h2>
<div class="code-block">
--format jpg
--width 1920
--height 1080
--window 2048
--hop 512
--sample-rate 44100
--style classic
--viz spectrogram
</div>
</section>
- WAV (PCM 8/16/24/32-bit, 32/64-bit float, WAVE_FORMAT_EXTENSIBLE) and MP3 are decoded in pure Go via the bundled decoders.
- Anything else falls through to `ffmpeg` (32-bit float little-endian, mono, `--sample-rate` Hz; default `44100`).
- Stereo or multichannel input is averaged to mono.
- `--start` / `--duration` slice the decoded sample buffer in seconds before windowing.
See [Decoding](decoding.md) for input formats, sample rate, ffmpeg lookup, and stdin usage.
## Windowing and FFT
- Window: **Hann**, applied per frame.
- Window size: `--window` samples (default `2048`, must be a power of two).
- Hop size: `--hop` samples (default `512`).
- Frame count: `1 + (len(samples) - window + hop - 1) / hop`.
- Bin count: `window / 2 + 1`.
- Bin spacing: `sampleRate / window` Hz per bin.
Magnitude is converted to decibels with `20·log10(mag + 1e-9)` for the base spectrogram. Per-feature pipelines (mel, chroma, mfcc) use linear power instead.
## Per-mode features
| mode | source | notes |
|------|--------|-------|
| `spectrogram` | STFT magnitude in dB | clamped to 5th98th percentile |
| `mel` | mel-warped power | log-magnitude; clamped 5th98th percentile |
| `chroma` | 12-bin pitch class | folds octaves; clamped 10th98th percentile |
| `mfcc` | DCT of mel power | strips pitch, keeps timbre |
| `hpss` | median filters on STFT | 9-frame harmonic + 9-frame percussive kernels |
| `selfsim` | cosine sim on chroma frames | gamma 1.4; clamped 10th98th percentile |
| `loudness` | per-frame RMS | clamped to 95th percentile |
| `tempogram` | onset autocorrelation | 30240 BPM, 256 bins |
| `flux` | frame-to-frame STFT delta | clamped to 95th percentile |
The percentile sampling reservoir is capped at 20 000 values per panel for speed; this is dense enough that boundaries are stable across runs.
## Rendering
- Each panel maps `(time × bin)` cells onto pixels at the panel's width × height.
- Values are normalized into `[0, 1]` against the per-panel min/max (after the percentile clamp), then passed through the chosen palette.
- Heatmap panels (mel, chroma, mfcc, selfsim, hpss halves, tempogram) render with `flipVert` so low frequencies are at the bottom.
- Multiple panels compose into a `ceil(sqrt(n))`-column grid with an 8 px gap (see [Rendering](rendering.md)).
- Encoder: PNG (lossless) or JPEG (quality 95).
## CLI defaults
```text
--format jpg
--width 1920
--height 1080
--window 2048
--hop 512
--sample-rate 44100
--style classic
--viz spectrogram
```
Full reference: [CLI](cli.md).
## Related pages
- [Visualizations](visualizations.md) — per-mode descriptions.
- [Palettes](palettes.md) — gradient stops.
- [Decoding](decoding.md) — input handling.
- [Rendering](rendering.md) — output and batch use.

120
docs/visualizations.md Normal file
View File

@ -0,0 +1,120 @@
---
title: Visualizations
description: "The nine visualization modes songsee can render: spectrogram, mel, chroma, hpss, selfsim, loudness, tempogram, mfcc, flux."
---
# Visualizations
`--viz` selects one or more visualization modes. Pass it once with a comma-separated list, or repeat it. Unknown names error out before any decoding runs.
```bash
songsee track.mp3 --viz spectrogram
songsee track.mp3 --viz mel,chroma,hpss
songsee track.mp3 --viz spectrogram --viz flux
```
When more than one mode is selected, songsee composes a square-ish grid (`ceil(sqrt(n))` columns) with an 8 px gap between cells, all sized to fit `--width` × `--height`.
## spectrogram
Time × frequency magnitude. The base FFT view: a Hann-windowed STFT converted to decibels (`20·log10(mag + 1e-9)`). The X axis is time, the Y axis is linear frequency from `--min-freq` to `--max-freq` (Nyquist by default), and each pixel's brightness is the magnitude in that time-frequency cell.
Use it when you want raw spectral truth — verifying decode, hunting harmonics, identifying transients.
```bash
songsee track.mp3 --viz spectrogram
```
## mel
Perceptual frequency scale. Same STFT, but bins are warped onto the mel scale, which weights low frequencies more heavily — closer to how humans hear pitch.
Good for vocal and tonal content; the structure of speech and melody jumps out compared to a linear spectrogram.
```bash
songsee track.mp3 --viz mel --min-freq 80 --max-freq 8000
```
## chroma
12-bin pitch class. Energy is folded across octaves into the twelve semitones (C, C♯, D, …). The Y axis is pitch class, the X axis is time.
Reveals harmonic and key content — chord progressions, modulations, repetition between sections.
```bash
songsee track.mp3 --viz chroma
```
## hpss
Harmonic vs percussive separation. Median-filters the spectrogram twice (9-frame kernels) to split it into a harmonic top half (sustained tones) and a percussive bottom half (transients).
Use it to see where the kit and where the melody live in the same track.
```bash
songsee track.mp3 --viz hpss
```
## selfsim
Self-similarity matrix on chroma frames. Each pixel `(i, j)` is the cosine similarity between chroma frame `i` and frame `j`, with a gentle gamma (1.4) for contrast.
Brings out song structure: verses repeat as bright off-diagonal stripes; choruses form clear blocks.
```bash
songsee track.mp3 --viz selfsim
```
## loudness
Frame-wise RMS over time. A waveform-style envelope with the X axis as time and the height as energy, clamped to the 95th percentile so peaks don't crush the rest.
Good for spotting dynamics, fade-ins, and silence.
```bash
songsee track.mp3 --viz loudness
```
## tempogram
Tempo variation over time. An autocorrelation-style heatmap of the onset envelope, scanning 30240 BPM in 256 bins.
Reveals tempo drift, rubato, and switches between rhythmic feels.
```bash
songsee track.mp3 --viz tempogram
```
## mfcc
Mel-frequency cepstral coefficients — the classic timbre fingerprint. Each row is one cepstral coefficient over time.
Strips pitch and leaves "color"; useful for distinguishing instruments, voices, or sections that share notes but not tone.
```bash
songsee track.mp3 --viz mfcc
```
## flux
Spectral flux — frame-to-frame magnitude change. A 1-D envelope with peaks at onsets and discontinuities, clamped to the 95th percentile.
Use it to find note onsets, edits, or anything sudden.
```bash
songsee track.mp3 --viz flux
```
## Combining
```bash
songsee track.mp3 --viz spectrogram,mel,chroma,hpss,selfsim,loudness,tempogram,mfcc,flux
```
All nine in one 1920×1080 grid (3×3). Mix and match with the same syntax. Each panel auto-contrasts independently — comparing absolute values across panels is not meaningful, comparing structure is.
## Related pages
- [Palettes](palettes.md) — color maps applied to every panel.
- [Pipeline](spec.md) — windowing, FFT, bin mapping, normalization.
- [CLI](cli.md) — every flag with its default.

708
scripts/build-docs-site.mjs Normal file
View File

@ -0,0 +1,708 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { css, faviconSvg, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs";
const root = process.cwd();
const docsDir = path.join(root, "docs");
const outDir = path.join(root, "dist", "docs-site");
const repoBase = "https://github.com/openclaw/songsee";
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : "";
const productName = "songsee";
const productTagline = "See sound as living color.";
const productDescription =
"A single Go CLI that turns audio into modern spectrogram and feature-panel images — fast WAV/MP3 decode, ffmpeg fallback, nine visualization modes, six palettes.";
const brewInstall = "brew install steipete/tap/songsee";
const productEyebrow = "Spectral imaging · One CLI";
const productSubtitle = "Spectrogram CLI";
const homeServices = ["spectrogram", "mel", "chroma", "hpss", "selfsim", "loudness", "tempogram", "mfcc", "flux"];
const sections = [
["Start", ["index.md", "install.md", "quickstart.md"]],
["Visualize", ["visualizations.md", "palettes.md"]],
["Audio & Output", ["decoding.md", "rendering.md"]],
["Reference", ["cli.md", "spec.md", "RELEASING.md"]],
];
const buildExcludes = [];
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const allPages = allMarkdown(docsDir).map((file) => {
const rel = path.relative(docsDir, file).replaceAll(path.sep, "/");
const raw = fs.readFileSync(file, "utf8");
const { frontmatter, body } = parseFrontmatter(raw);
const cleaned = stripStrayDirectives(body);
const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, ".md"));
return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
});
const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const permalinkMap = new Map();
for (const page of pages) {
if (page.frontmatter.permalink) {
permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
}
}
const nav = sections
.map(([name, rels]) => ({
name,
pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
}))
.filter((section) => section.pages.length);
const sectionByRel = new Map();
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
const orderedPages = nav.flatMap((s) => s.pages);
for (const page of pages) {
const html = markdownToHtml(page.markdown, page.rel);
const toc = tocFromHtml(html);
const idx = orderedPages.findIndex((p) => p.rel === page.rel);
const prev = idx > 0 ? orderedPages[idx - 1] : null;
const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
const sectionName = sectionByRel.get(page.rel) || "Reference";
const pageOut = path.join(outDir, page.outRel);
fs.mkdirSync(path.dirname(pageOut), { recursive: true });
fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), "utf8");
}
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
copyStaticAsset("social-card.svg");
copyStaticAsset("social-card.png");
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
if (cname) fs.writeFileSync(path.join(outDir, "CNAME"), cname, "utf8");
validateLinks(outDir);
console.log(`built docs site: ${path.relative(root, outDir)}`);
function readCname() {
for (const candidate of [path.join(docsDir, "CNAME"), path.join(root, "CNAME")]) {
if (fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8").trim();
}
return "";
}
function copyStaticAsset(name) {
const source = path.join(docsDir, name);
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(outDir, name));
}
function parseFrontmatter(raw) {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { frontmatter: {}, body: raw };
const fm = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
if (!m) continue;
let value = m[2];
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
fm[m[1]] = value;
}
return { frontmatter: fm, body: raw.slice(match[0].length) };
}
function stripStrayDirectives(body) {
return body
.replace(/\r\n/g, "\n")
.split("\n")
.filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
.map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ""))
.join("\n");
}
function normalizePermalink(value) {
let v = value.trim();
if (!v) return "/";
if (!v.startsWith("/")) v = `/${v}`;
if (v.length > 1 && v.endsWith("/")) v = v.slice(0, -1);
return v;
}
function allMarkdown(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allMarkdown(full);
return entry.name.endsWith(".md") ? [full] : [];
})
.sort();
}
function outPath(rel, frontmatter = {}) {
if (frontmatter.permalink) {
const permalink = normalizePermalink(frontmatter.permalink);
if (permalink === "/") return "index.html";
return `${permalink.slice(1)}/index.html`;
}
if (rel === "index.md") return "index.html";
if (rel === "README.md") return "index.html";
if (rel.endsWith("/README.md")) return rel.replace(/README\.md$/, "index.html");
return rel.replace(/\.md$/, ".html");
}
function firstHeading(markdown) {
return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}
function titleize(input) {
return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase());
}
function markdownToHtml(markdown, currentRel) {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html = [];
let paragraph = [];
let list = null;
let fence = null;
let blockquote = [];
const flushParagraph = () => {
if (!paragraph.length) return;
html.push(`<p>${inline(paragraph.join(" "), currentRel)}</p>`);
paragraph = [];
};
const closeList = () => {
if (!list) return;
html.push(`</${list}>`);
list = null;
};
const flushBlockquote = () => {
if (!blockquote.length) return;
const inner = markdownToHtml(blockquote.join("\n"), currentRel);
html.push(`<blockquote>${inner}</blockquote>`);
blockquote = [];
};
const splitRow = (line) => {
let trimmed = line.trim();
if (trimmed.startsWith("|")) trimmed = trimmed.slice(1);
if (trimmed.endsWith("|") && !trimmed.endsWith("\\|")) trimmed = trimmed.slice(0, -1);
const cells = [];
let current = "";
for (let idx = 0; idx < trimmed.length; idx++) {
const char = trimmed[idx];
if (char === "\\" && trimmed[idx + 1] === "|") {
current += "\\|";
idx += 1;
continue;
}
if (char === "|") {
cells.push(current.trim().replace(/\\\|/g, "|"));
current = "";
continue;
}
current += char;
}
cells.push(current.trim().replace(/\\\|/g, "|"));
return cells;
};
const isDivider = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
if (fenceMatch) {
flushParagraph();
closeList();
flushBlockquote();
if (fence) {
const body = highlightCode(fence.lines.join("\n"), fence.lang);
html.push(`<pre><code class="language-${escapeAttr(fence.lang)}">${body}</code></pre>`);
fence = null;
} else {
fence = { lang: fenceMatch[1] || "text", lines: [] };
}
continue;
}
if (fence) {
fence.lines.push(line);
continue;
}
if (/^>\s?/.test(line)) {
flushParagraph();
closeList();
blockquote.push(line.replace(/^>\s?/, ""));
continue;
}
flushBlockquote();
if (!line.trim()) {
flushParagraph();
closeList();
continue;
}
if (/^\s*---+\s*$/.test(line)) {
flushParagraph();
closeList();
html.push("<hr>");
continue;
}
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
flushParagraph();
closeList();
const level = heading[1].length;
const text = heading[2].trim();
const id = slug(text);
const inner = inline(text, currentRel);
if (level === 1) {
html.push(`<h1 id="${id}">${inner}</h1>`);
} else {
html.push(`<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`);
}
continue;
}
if (line.trimStart().startsWith("|") && line.includes("|", line.indexOf("|") + 1) && isDivider(lines[i + 1] || "")) {
flushParagraph();
closeList();
const header = splitRow(line);
const aligns = splitRow(lines[i + 1]).map((cell) => {
const left = cell.startsWith(":");
const right = cell.endsWith(":");
return right && left ? "center" : right ? "right" : left ? "left" : "";
});
i += 1;
const rows = [];
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith("|")) {
i += 1;
rows.push(splitRow(lines[i]));
}
const th = header.map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</th>`).join("");
const tb = rows.map((r) => `<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ""}>${inline(c, currentRel)}</td>`).join("")}</tr>`).join("");
html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
continue;
}
const bullet = line.match(/^\s*-\s+(.+)$/);
const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet || numbered) {
flushParagraph();
const tag = bullet ? "ul" : "ol";
if (list && list !== tag) closeList();
if (!list) {
list = tag;
html.push(`<${tag}>`);
}
html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
continue;
}
paragraph.push(line.trim());
}
flushParagraph();
closeList();
flushBlockquote();
return html.join("\n");
}
function inline(text, currentRel) {
const stash = [];
let out = text.replace(/`([^`]+)`/g, (_, code) => {
stash.push(`<code>${escapeHtml(code)}</code>`);
return `\u0000${stash.length - 1}\u0000`;
});
out = escapeHtml(out)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, "$1<em>$2</em>")
.replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, "$1<em>$2</em>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`)
.replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
out = out.replace(/\\\|/g, "|");
out = out.replace(/&lt;br&gt;/g, "<br>");
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => stash[Number(i)]);
}
function rewriteHref(href, currentRel) {
if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
const [raw, hash = ""] = href.split("#");
if (!raw) return hash ? `#${hash}` : "";
if (raw.startsWith("/")) {
const target = permalinkMap.get(normalizePermalink(raw));
if (target) {
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
const out = hrefToOutRel(target.outRel, currentOut);
return hash ? `${out}#${hash}` : out;
}
return href;
}
if (!raw.endsWith(".md")) return href;
const from = path.posix.dirname(currentRel);
const target = path.posix.normalize(path.posix.join(from, raw));
let rewritten = pageMap.get(target)?.outRel || outPath(target);
const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
rewritten = hrefToOutRel(rewritten, currentOut);
return `${rewritten}${hash ? `#${hash}` : ""}`;
}
function tocFromHtml(html) {
const items = [];
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) return "";
return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
.map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
.join("")}</nav>`;
}
function isHomePage(page) {
if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === "/") return true;
return page.rel === "index.md" || page.rel === "README.md";
}
function homeHero(page) {
const description = page.frontmatter.description || productDescription;
const installRel = pageMap.get("install.md")?.outRel
? hrefToOutRel(pageMap.get("install.md").outRel, page.outRel)
: "install.html";
const quickstartRel = pageMap.get("quickstart.md")?.outRel
? hrefToOutRel(pageMap.get("quickstart.md").outRel, page.outRel)
: "quickstart.html";
return `<header class="home-hero">
<p class="eyebrow">${escapeHtml(productEyebrow)}</p>
<h1>${escapeHtml(productTagline)}</h1>
<p class="lede">${escapeHtml(description)}</p>
<div class="home-cta">
<a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
<a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
<div class="home-install" aria-label="Install with Homebrew">
<span class="prompt" aria-hidden="true">$</span>
<code>${escapeHtml(brewInstall)}</code>
</div>
</div>
<div class="home-services" aria-label="Visualization modes">
${homeServices.map((s) => `<span>${escapeHtml(s)}</span>`).join("")}
</div>
<p class="muted"><a href="${installRel}">Other install options </a></p>
</header>`;
}
function standardHero(page, sectionName, editUrl) {
return `<header class="hero">
<div class="hero-text">
<p class="eyebrow">${escapeHtml(sectionName)}</p>
<h1>${escapeHtml(page.title)}</h1>
</div>
<div class="hero-meta">
<a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
<a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
</div>
</header>`;
}
function layout({ page, html, toc, prev, next, sectionName }) {
const depth = page.outRel.split("/").length - 1;
const rootPrefix = depth ? "../".repeat(depth) : "";
const editUrl = `${repoEditBase}/${page.rel}`;
const home = isHomePage(page);
const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : "";
const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
const articleClass = home ? "doc doc-home" : "doc";
const tocBlock = home ? "" : toc;
const titleSuffix = home ? `${productName}${productTagline}` : `${page.title}${productName}`;
const description = page.frontmatter.description || (home ? productDescription : `${page.title}${productName} CLI documentation.`);
const canonicalUrl = pageCanonicalUrl(page);
const socialImage = siteBase ? `${siteBase}/social-card.png` : `${rootPrefix}social-card.png`;
const socialMeta = [
["link", "rel", "canonical", "href", canonicalUrl],
["meta", "property", "og:type", "content", "website"],
["meta", "property", "og:site_name", "content", productName],
["meta", "property", "og:title", "content", titleSuffix],
["meta", "property", "og:description", "content", description],
["meta", "property", "og:url", "content", canonicalUrl],
["meta", "property", "og:image", "content", socialImage],
["meta", "property", "og:image:width", "content", "1200"],
["meta", "property", "og:image:height", "content", "630"],
["meta", "name", "twitter:card", "content", "summary_large_image"],
["meta", "name", "twitter:title", "content", titleSuffix],
["meta", "name", "twitter:description", "content", description],
["meta", "name", "twitter:image", "content", socialImage],
].map(tagHtml).join("\n ");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(titleSuffix)}</title>
<meta name="description" content="${escapeAttr(description)}">
${socialMeta}
<link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>${preThemeScript()}</script>
<style>${css()}</style>
</head>
<body${home ? ' class="home"' : ""}>
<button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<a class="brand" href="${hrefToOutRel("index.html", page.outRel)}" aria-label="${productName} docs home">
<span class="mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span><strong>${escapeHtml(productName)}</strong><small>${escapeHtml(productSubtitle)}</small></span>
</a>
${themeToggleHtml()}
</div>
<label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="spectrogram, mel, palettes"></label>
<nav>${navHtml(page)}</nav>
</aside>
<main>
${heroBlock}
<div class="doc-grid${home ? " doc-grid-home" : ""}">
<article class="${articleClass}">${html}${prevNext}</article>
${tocBlock}
</div>
</main>
</div>
<script>${js()}</script>
</body>
</html>`;
}
function pageCanonicalUrl(page) {
if (!siteBase) return page.outRel;
if (page.outRel === "index.html") return `${siteBase}/`;
const rel = page.outRel.endsWith("/index.html") ? page.outRel.slice(0, -"index.html".length) : page.outRel;
return `${siteBase}/${rel}`;
}
function tagHtml([tag, k1, v1, k2, v2]) {
return tag === "link" ? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">` : `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
}
function pageNavHtml(prev, next, currentOutRel) {
const cell = (page, dir) => {
if (!page) return "";
return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === "prev" ? "Previous" : "Next"}</small><span>${escapeHtml(page.title)}</span></a>`;
};
return `<nav class="page-nav" aria-label="Pager">${cell(prev, "prev")}${cell(next, "next")}</nav>`;
}
function navHtml(currentPage) {
return nav
.map((section) => `<section><h2>${escapeHtml(section.name)}</h2>${section.pages.map((page) => {
const href = hrefToOutRel(page.outRel, currentPage.outRel);
const active = page.rel === currentPage.rel ? " active" : "";
return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
}).join("")}</section>`)
.join("");
}
function navTitle(page) {
if (page.rel === "index.md") return "Overview";
return page.title;
}
function hrefToOutRel(targetOutRel, currentOutRel) {
const currentDir = path.posix.dirname(currentOutRel);
if (targetOutRel.endsWith("/index.html")) {
const targetDir = targetOutRel.slice(0, -"index.html".length);
const rel = path.posix.relative(currentDir, targetDir || ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
if (targetOutRel === "index.html") {
const rel = path.posix.relative(currentDir, ".") || ".";
return rel.endsWith("/") ? rel : `${rel}/`;
}
return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}
function slug(text) {
return text.toLowerCase().replace(/`/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function highlightCode(code, lang) {
const language = (lang || "text").toLowerCase();
if (language === "bash" || language === "sh" || language === "shell" || language === "zsh" || language === "console") {
return highlightShell(code);
}
if (language === "json" || language === "json5") return highlightJson(code);
if (language === "ts" || language === "typescript" || language === "js" || language === "javascript" || language === "tsx" || language === "jsx") {
return highlightJs(code);
}
if (language === "go" || language === "golang") return highlightGo(code);
if (language === "yaml" || language === "yml") return highlightYaml(code);
return escapeHtml(code);
}
function stashToken(idx) {
return String.fromCharCode(0xe000 + idx);
}
function restoreStashTokens(value, stash) {
return value.replace(/[\ue000-\uf8ff]/g, (token) => {
const idx = token.charCodeAt(0) - 0xe000;
return stash[idx] ?? "";
});
}
function withStash(code, patterns) {
const stash = [];
let working = code;
for (const [re, cls] of patterns) {
working = working.replace(re, (match) => {
const idx = stash.length;
stash.push(`<span class="${cls}">${escapeHtml(match)}</span>`);
return stashToken(idx);
});
}
return restoreStashTokens(escapeHtml(working), stash);
}
function highlightShell(code) {
return code
.split("\n")
.map((line) => {
if (/^\s*#/.test(line)) return `<span class="hl-c">${escapeHtml(line)}</span>`;
const promptMatch = line.match(/^(\s*)([$#>])(\s+)(.*)$/);
if (promptMatch) {
const [, lead, sym, gap, rest] = promptMatch;
return `${escapeHtml(lead)}<span class="hl-p">${escapeHtml(sym)}</span>${escapeHtml(gap)}${highlightShellLine(rest)}`;
}
return highlightShellLine(line);
})
.join("\n");
}
function highlightShellLine(line) {
const stash = [];
const stashAdd = (match, cls) => {
const idx = stash.length;
stash.push(`<span class="${cls}">${escapeHtml(match)}</span>`);
return stashToken(idx);
};
let working = line;
working = working.replace(/(?:'[^']*'|"[^"]*")/g, (m) => stashAdd(m, "hl-s"));
working = working.replace(/\s#.*$/g, (m) => stashAdd(m, "hl-c"));
working = working.replace(/(^|\s)(--?[A-Za-z][A-Za-z0-9-]*)/g, (_, lead, flag) => `${escapeHtml(lead)}${stashAdd(flag, "hl-f")}`);
working = working.replace(/\b(songsee|ffmpeg|brew|go|git|gh|make|sudo|cd|export|cat|curl|find|xargs|parallel|ls|mv|cp|rm|mkdir|tail|node|npm|pnpm|yarn|ssh|imgcat)\b/g, (m) => stashAdd(m, "hl-cmd"));
working = working.replace(/\b(\d+(?:\.\d+)?)\b/g, (m) => stashAdd(m, "hl-n"));
return restoreStashTokens(escapeHtml(working), stash);
}
function highlightJson(code) {
return withStash(code, [
[/"(?:\\.|[^"\\])*"\s*:/g, "hl-k"],
[/"(?:\\.|[^"\\])*"/g, "hl-s"],
[/\b(true|false|null)\b/g, "hl-m"],
[/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/gi, "hl-n"],
]);
}
function highlightJs(code) {
return withStash(code, [
[/\/\/[^\n]*/g, "hl-c"],
[/\/\*[\s\S]*?\*\//g, "hl-c"],
[/`(?:\\.|[^`\\])*`/g, "hl-s"],
[/"(?:\\.|[^"\\])*"/g, "hl-s"],
[/'(?:\\.|[^'\\])*'/g, "hl-s"],
[/\b(const|let|var|function|return|if|else|for|while|switch|case|break|continue|class|extends|new|import|from|export|default|async|await|try|catch|finally|throw|typeof|instanceof|interface|type|enum|as|of|in|null|undefined|true|false|this)\b/g, "hl-k"],
[/\b(\d+(?:\.\d+)?)\b/g, "hl-n"],
]);
}
function highlightGo(code) {
return withStash(code, [
[/\/\/[^\n]*/g, "hl-c"],
[/\/\*[\s\S]*?\*\//g, "hl-c"],
[/`[^`]*`/g, "hl-s"],
[/"(?:\\.|[^"\\])*"/g, "hl-s"],
[/\b(package|import|func|return|if|else|for|range|switch|case|break|continue|default|type|struct|interface|map|chan|go|defer|select|var|const|nil|true|false|iota)\b/g, "hl-k"],
[/\b(\d+(?:\.\d+)?)\b/g, "hl-n"],
]);
}
function highlightYaml(code) {
return code
.split("\n")
.map((line) => {
if (/^\s*#/.test(line)) return `<span class="hl-c">${escapeHtml(line)}</span>`;
const m = line.match(/^(\s*-?\s*)([A-Za-z0-9_.-]+)(\s*:)(.*)$/);
if (m) {
const [, lead, key, colon, rest] = m;
return `${escapeHtml(lead)}<span class="hl-k">${escapeHtml(key)}</span>${escapeHtml(colon)}${highlightYamlValue(rest)}`;
}
return escapeHtml(line);
})
.join("\n");
}
function highlightYamlValue(rest) {
if (!rest.trim()) return escapeHtml(rest);
const trimmed = rest.trim();
if (/^["'].*["']$/.test(trimmed)) {
return escapeHtml(rest.replace(trimmed, "")) + `<span class="hl-s">${escapeHtml(trimmed)}</span>`;
}
if (/^(true|false|null|~)$/i.test(trimmed)) {
return escapeHtml(rest.replace(trimmed, "")) + `<span class="hl-m">${escapeHtml(trimmed)}</span>`;
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return escapeHtml(rest.replace(trimmed, "")) + `<span class="hl-n">${escapeHtml(trimmed)}</span>`;
}
return escapeHtml(rest);
}
function validateLinks(outputDir) {
const failures = [];
for (const file of allHtml(outputDir)) {
const html = fs.readFileSync(file, "utf8");
for (const match of html.matchAll(/href="([^"]+)"/g)) {
const href = match[1];
if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
const [rawPath, anchor = ""] = href.split("#");
const targetPath = rawPath
? path.resolve(path.dirname(file), rawPath)
: file;
const target = fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
? path.join(targetPath, "index.html")
: targetPath;
if (!fs.existsSync(target)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
continue;
}
if (anchor) {
const targetHtml = fs.readFileSync(target, "utf8");
if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
}
}
}
}
if (failures.length) {
throw new Error(`broken docs links:\n${failures.join("\n")}`);
}
}
function allHtml(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return allHtml(full);
return entry.name.endsWith(".html") ? [full] : [];
})
.sort();
}

View File

@ -0,0 +1,296 @@
export function css() {
return `
:root{
--ink:#0f1115;
--text:#1f2328;
--muted:#6b7280;
--subtle:#9aa1ab;
--bg:#fafafa;
--paper:#ffffff;
--accent:#6f4cff;
--accent-soft:rgba(111,76,255,.10);
--accent-strong:#5938e5;
--btn-primary-bg:#5938e5;
--btn-primary-bg-hover:#4528c4;
--s-violet:#6f4cff;--s-magenta:#e0438a;--s-amber:#ff8b3d;--s-cyan:#36d3c4;
--line:#e5e7eb;
--line-soft:#eef0f3;
--code-bg:#0f172a;
--code-fg:#e6edf3;
--code-inline-fg:#1c2128;
--code-border:#1f2937;
--pill-border:#dbe2eb;
--shadow-card:0 4px 14px rgba(15,17,21,.08);
--scrollbar:#cbd5e1;
--hl-keyword:#7aa2ff;
--hl-string:#9ece6a;
--hl-number:#e0a96d;
--hl-comment:#7c8597;
--hl-flag:#c4a4ff;
--hl-meta:#f08aa0;
--hl-prompt:#64748b;
}
:root[data-theme="dark"]{
--ink:#f3f5f9;
--text:#cbd2dc;
--muted:#8d96a4;
--subtle:#5d6371;
--bg:#0c0e14;
--paper:#171a23;
--accent:#a48bff;
--accent-soft:rgba(164,139,255,.16);
--accent-strong:#bda8ff;
--btn-primary-bg:#5938e5;
--btn-primary-bg-hover:#7253ff;
--line:#262a36;
--line-soft:#1d2029;
--code-bg:#06080d;
--code-fg:#e6edf3;
--code-inline-fg:#e6edf3;
--code-border:#1c2030;
--pill-border:#2a2f3c;
--shadow-card:0 4px 18px rgba(0,0,0,.45);
--scrollbar:#3a4154;
--hl-keyword:#7aa2ff;
--hl-string:#a6e3a1;
--hl-number:#f0a868;
--hl-comment:#6b7388;
--hl-flag:#c4a4ff;
--hl-meta:#ff8aa0;
--hl-prompt:#7e8ba3;
}
:root{color-scheme:light}
:root[data-theme="dark"]{color-scheme:dark}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:24px}
body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11";transition:background-color .18s,color .18s}
::selection{background:var(--accent);color:#fff}
a{color:var(--accent);text-decoration:none;transition:color .12s}
a:hover{text-decoration:underline;text-underline-offset:.2em}
.shell{display:grid;grid-template-columns:268px minmax(0,1fr);min-height:100vh}
.sidebar{position:sticky;top:0;height:100vh;overflow:auto;padding:24px 22px;background:var(--paper);border-right:1px solid var(--line);scrollbar-width:thin;scrollbar-color:var(--line) transparent;transition:background-color .18s,border-color .18s}
.sidebar::-webkit-scrollbar{width:6px}
.sidebar::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
.sidebar-head{display:flex;align-items:center;gap:10px;margin-bottom:24px}
.brand{display:flex;align-items:center;gap:11px;color:var(--ink);text-decoration:none;flex:1;min-width:0}
.brand:hover{text-decoration:none}
.brand .mark{display:grid;grid-template-columns:repeat(2,12px);grid-template-rows:repeat(2,12px);gap:3px;flex:0 0 27px}
.brand .mark i{display:block;border-radius:3px}
.brand .mark i:nth-child(1){background:var(--s-violet)}
.brand .mark i:nth-child(2){background:var(--s-magenta)}
.brand .mark i:nth-child(3){background:var(--s-cyan)}
.brand .mark i:nth-child(4){background:var(--s-amber)}
.brand strong{display:block;font-size:1.05rem;line-height:1.1;font-weight:600;letter-spacing:0;color:var(--ink)}
.brand small{display:block;color:var(--muted);font-size:.74rem;margin-top:3px;font-weight:400}
.theme-toggle{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:34px;height:34px;border-radius:8px;border:1px solid var(--line);background:var(--paper);color:var(--muted);cursor:pointer;padding:0;transition:border-color .15s,color .15s,background-color .15s,transform .12s}
.theme-toggle:hover{border-color:var(--ink);color:var(--ink)}
.theme-toggle:active{transform:scale(.94)}
.theme-toggle svg{width:16px;height:16px;display:block}
.theme-icon-sun{display:none}
:root[data-theme="dark"] .theme-icon-sun{display:block}
:root[data-theme="dark"] .theme-icon-moon{display:none}
.search{display:block;margin:0 0 22px}
.search span{display:block;color:var(--muted);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0;margin-bottom:7px}
.search input{width:100%;border:1px solid var(--line);background:var(--paper);border-radius:8px;padding:9px 12px;font:inherit;font-size:.9rem;color:var(--text);outline:none;transition:border-color .15s,box-shadow .15s,background-color .18s}
.search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
nav section{margin:0 0 18px}
nav h2{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 6px;font-weight:600}
.nav-link{display:block;color:var(--text);text-decoration:none;border-radius:6px;padding:5px 10px;margin:1px 0;font-size:.9rem;line-height:1.4;transition:background .12s,color .12s}
.nav-link:hover{background:var(--line-soft);color:var(--ink);text-decoration:none}
.nav-link.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
main{min-width:0;padding:32px clamp(20px,4.5vw,56px) 80px;max-width:1180px;margin:0 auto;width:100%}
.hero{display:flex;align-items:flex-end;justify-content:space-between;gap:22px;border-bottom:1px solid var(--line);padding:8px 0 22px;margin-bottom:8px;flex-wrap:wrap}
.hero-text{min-width:0;flex:1 1 320px}
.eyebrow{margin:0 0 8px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;font-size:.7rem}
.hero h1{font-size:2.25rem;line-height:1.1;letter-spacing:0;margin:0;font-weight:700;color:var(--ink)}
.hero-meta{display:flex;gap:8px;flex:0 0 auto;flex-wrap:wrap}
.repo,.edit,.btn-ghost{border:1px solid var(--line);color:var(--text);text-decoration:none;border-radius:7px;padding:6px 11px;font-weight:500;font-size:.83rem;background:var(--paper);transition:border-color .15s,color .15s,background .15s}
.repo:hover,.edit:hover,.btn-ghost:hover{border-color:var(--ink);color:var(--ink);text-decoration:none}
.edit{color:var(--muted)}
.home-hero{padding:14px 0 28px;margin-bottom:8px;border-bottom:1px solid var(--line)}
.home-hero h1{font-size:3.25rem;line-height:1.18;letter-spacing:0;margin:0 0 .25em;padding-bottom:.08em;font-weight:700;background:linear-gradient(120deg,var(--s-violet),var(--s-magenta) 45%,var(--s-amber) 80%,var(--s-cyan));-webkit-background-clip:text;background-clip:text;color:transparent}
.home-hero .lede{font-size:1.18rem;line-height:1.55;color:var(--text);margin:0 0 1.2em;max-width:60ch}
.home-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 18px}
.home-cta .btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:10px 16px;font-weight:600;font-size:.92rem;text-decoration:none;transition:background .15s,border-color .15s,color .15s,transform .12s}
.home-cta .btn-primary{background:var(--btn-primary-bg);color:#fff;border:1px solid var(--btn-primary-bg)}
.home-cta .btn-primary:hover{background:var(--btn-primary-bg-hover);border-color:var(--btn-primary-bg-hover);text-decoration:none;color:#fff}
.home-cta .btn-ghost{padding:10px 16px}
.home-install{display:flex;align-items:center;gap:12px;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:10px 10px 10px 16px;font:500 .9rem/1.2 "JetBrains Mono","SF Mono",ui-monospace,monospace;max-width:32em;border:1px solid #1f2937}
.home-install .prompt{color:#64748b;user-select:none;flex:0 0 auto}
.home-install code{flex:1;background:transparent;border:0;color:var(--code-fg);font:inherit;padding:0;white-space:pre;overflow:hidden;text-overflow:ellipsis}
.home-install .copy{flex:0 0 auto;background:rgba(255,255,255,.08);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:5px 11px;font:500 .72rem/1 "Inter",sans-serif;cursor:pointer;transition:background .15s,border-color .15s}
.home-install .copy:hover{background:rgba(255,255,255,.16)}
.home-install .copy.copied{background:var(--accent);border-color:var(--accent)}
.home-services{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 18px}
.home-services span{display:inline-block;padding:3px 9px;border:1px solid var(--line);border-radius:999px;font-size:.78rem;color:var(--muted);background:var(--paper)}
.doc-grid{display:grid;grid-template-columns:minmax(0,1fr);gap:48px;margin-top:24px}
.doc-grid-home{margin-top:8px}
@media(min-width:1180px){.doc-grid{grid-template-columns:minmax(0,72ch) 200px;justify-content:start}.doc-grid-home{grid-template-columns:minmax(0,76ch);justify-content:start}}
.doc{min-width:0;max-width:72ch;overflow-wrap:break-word}
.doc-home{max-width:76ch}
.doc h1{font-size:2.6rem;line-height:1.08;letter-spacing:0;margin:0 0 .4em;font-weight:700;color:var(--ink)}
body:not(.home) .doc>h1:first-child{display:none}
.doc h2{font-size:1.45rem;line-height:1.2;margin:2em 0 .5em;font-weight:600;letter-spacing:0;color:var(--ink);position:relative}
.doc h3{font-size:1.1rem;margin:1.7em 0 .35em;position:relative;font-weight:600;color:var(--ink);letter-spacing:0}
.doc h4{font-size:.98rem;margin:1.4em 0 .25em;color:var(--ink);position:relative;font-weight:600}
.doc h2:first-child,.doc h3:first-child,.doc h4:first-child{margin-top:.2em}
.doc :is(h2,h3,h4) .anchor{position:absolute;left:-1.05em;top:0;color:var(--subtle);opacity:0;text-decoration:none;font-weight:400;padding-right:.3em;transition:opacity .12s,color .12s}
.doc :is(h2,h3,h4):hover .anchor{opacity:.7}
.doc :is(h2,h3,h4) .anchor:hover{opacity:1;color:var(--accent);text-decoration:none}
.doc p{margin:0 0 1.05em}
.doc ul,.doc ol{padding-left:1.3rem;margin:0 0 1.15em}
.doc li{margin:.25em 0}
.doc li>p{margin:0 0 .4em}
.doc strong{font-weight:600;color:var(--ink)}
.doc em{font-style:italic}
.doc code{font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;font-size:.84em;background:var(--line-soft);border:1px solid var(--line);border-radius:5px;padding:.08em .35em;color:var(--code-inline-fg)}
.doc pre{position:relative;overflow:auto;background:var(--code-bg);color:var(--code-fg);border-radius:8px;padding:14px 18px;margin:1.3em 0;font-size:.85em;line-height:1.6;scrollbar-width:thin;scrollbar-color:#334155 transparent;border:1px solid var(--code-border)}
.doc pre::-webkit-scrollbar{height:8px;width:8px}
.doc pre::-webkit-scrollbar-thumb{background:#334155;border-radius:8px}
.doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre}
.doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s}
.doc pre:hover .copy,.doc pre .copy:focus{opacity:1}
.doc pre .copy:hover{background:rgba(255,255,255,.12)}
.doc pre .copy.copied{background:var(--accent);border-color:var(--accent);opacity:1}
.doc pre .hl-c{color:var(--hl-comment);font-style:italic}
.doc pre .hl-s{color:var(--hl-string)}
.doc pre .hl-n{color:var(--hl-number)}
.doc pre .hl-k{color:var(--hl-keyword);font-weight:600}
.doc pre .hl-f{color:var(--hl-flag)}
.doc pre .hl-m{color:var(--hl-meta);font-weight:600}
.doc pre .hl-p{color:var(--hl-prompt);user-select:none}
.doc pre .hl-cmd{color:var(--hl-keyword);font-weight:600}
.doc blockquote{margin:1.4em 0;padding:10px 16px;border-left:3px solid var(--accent);background:var(--accent-soft);border-radius:0 8px 8px 0;color:var(--text)}
.doc blockquote p:last-child{margin-bottom:0}
.doc table{width:100%;border-collapse:collapse;margin:1.2em 0;font-size:.92em}
.doc th,.doc td{border-bottom:1px solid var(--line);padding:9px 10px;text-align:left;vertical-align:top}
.doc th{font-weight:600;color:var(--ink);background:var(--line-soft);border-bottom:1px solid var(--line)}
.doc hr{border:0;border-top:1px solid var(--line);margin:2.2em 0}
.toc{position:sticky;top:24px;align-self:start;font-size:.84rem;padding-left:14px;border-left:1px solid var(--line);max-height:calc(100vh - 48px);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.toc::-webkit-scrollbar{width:5px}
.toc::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px}
.toc h2{font-size:.66rem;color:var(--muted);text-transform:uppercase;letter-spacing:0;margin:0 0 10px;font-weight:600}
.toc a{display:block;color:var(--muted);text-decoration:none;padding:4px 0 4px 10px;line-height:1.35;border-left:2px solid transparent;margin-left:-12px;transition:color .12s,border-color .12s}
.toc a:hover{color:var(--ink);text-decoration:none}
.toc a.active{color:var(--accent);border-left-color:var(--accent);font-weight:500}
.toc-l3{padding-left:22px!important;font-size:.94em}
@media(max-width:1179px){.toc{display:none}}
.page-nav{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:48px;border-top:1px solid var(--line);padding-top:20px}
.page-nav>a{display:block;border:1px solid var(--line);background:var(--paper);border-radius:9px;padding:13px 16px;text-decoration:none;color:var(--text);transition:border-color .15s,transform .15s,box-shadow .15s,background-color .18s}
.page-nav>a:hover{border-color:var(--accent);text-decoration:none;color:var(--ink)}
.page-nav small{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:0;margin-bottom:5px;font-weight:600}
.page-nav span{display:block;font-weight:600;line-height:1.3;color:var(--ink)}
.page-nav-prev{text-align:left}
.page-nav-next{text-align:right;grid-column:2}
.page-nav-prev:only-child{grid-column:1}
.nav-toggle{display:none;position:fixed;top:14px;right:14px;top:calc(14px + env(safe-area-inset-top, 0px));right:calc(14px + env(safe-area-inset-right, 0px));z-index:20;width:40px;height:40px;border-radius:9px;background:var(--paper);border:1px solid var(--line);color:var(--ink);cursor:pointer;padding:10px 9px;flex-direction:column;align-items:stretch;justify-content:space-between;box-shadow:var(--shadow-card)}
.nav-toggle span{display:block;width:100%;height:2px;flex:0 0 2px;background:currentColor;border-radius:2px;transition:transform .2s,opacity .2s}
.nav-toggle[aria-expanded="true"] span:nth-child(1){transform:translateY(8px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] span:nth-child(2){opacity:0}
.nav-toggle[aria-expanded="true"] span:nth-child(3){transform:translateY(-8px) rotate(-45deg)}
@media(max-width:900px){
.shell{display:block}
.sidebar{position:fixed;inset:0 30% 0 0;max-width:320px;height:100vh;z-index:15;transform:translateX(-100%);transition:transform .25s ease,background-color .18s,border-color .18s;box-shadow:0 18px 40px rgba(0,0,0,.18);background:var(--paper);pointer-events:none}
.sidebar.open{transform:translateX(0);pointer-events:auto}
.nav-toggle{display:flex}
main{padding:64px 18px 56px}
.hero{padding-top:6px}
.hero h1{font-size:1.8rem}
.home-hero h1{font-size:2.45rem}
.doc h1{font-size:2.1rem}
.hero-meta{width:100%;justify-content:flex-start}
.home-hero{padding-top:8px}
.doc{padding:0}
.doc-grid{margin-top:18px;gap:24px}
.doc :is(h2,h3,h4) .anchor{display:none}
}
@media(max-width:520px){
main{padding:60px 14px 48px}
.doc pre{margin-left:-14px;margin-right:-14px;border-radius:0;border-left:0;border-right:0}
.home-install{flex-wrap:wrap}
}
`;
}
export function js() {
return `
const themeRoot=document.documentElement;
function applyTheme(mode){themeRoot.dataset.theme=mode;document.querySelectorAll('[data-theme-toggle]').forEach(b=>b.setAttribute('aria-pressed',mode==='dark'?'true':'false'))}
function storedTheme(){try{return localStorage.getItem('theme')}catch(e){return null}}
function persistTheme(mode){try{localStorage.setItem('theme',mode)}catch(e){}}
applyTheme(themeRoot.dataset.theme==='dark'?'dark':'light');
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{btn.addEventListener('click',()=>{const next=themeRoot.dataset.theme==='dark'?'light':'dark';applyTheme(next);persistTheme(next)})});
const systemDark=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e){if(storedTheme())return;applyTheme(e.matches?'dark':'light')}
if(systemDark){if(systemDark.addEventListener)systemDark.addEventListener('change',onSystemChange);else if(systemDark.addListener)systemDark.addListener(onSystemChange)}
const sidebar=document.querySelector('.sidebar');
const toggle=document.querySelector('.nav-toggle');
const mobileNav=window.matchMedia('(max-width: 900px)');
const sidebarFocusable='a[href],button,input,select,textarea,[tabindex]';
function setSidebarFocusable(enabled){
sidebar?.querySelectorAll(sidebarFocusable).forEach((el)=>{
if(enabled){
if(el.dataset.sidebarTabindex!==undefined){
if(el.dataset.sidebarTabindex)el.setAttribute('tabindex',el.dataset.sidebarTabindex);
else el.removeAttribute('tabindex');
delete el.dataset.sidebarTabindex;
}
}else if(el.dataset.sidebarTabindex===undefined){
el.dataset.sidebarTabindex=el.getAttribute('tabindex')??'';
el.setAttribute('tabindex','-1');
}
});
}
function setSidebarOpen(open){
if(!sidebar||!toggle)return;
sidebar.classList.toggle('open',open);
toggle.setAttribute('aria-expanded',open?'true':'false');
if(mobileNav.matches){
sidebar.inert=!open;
if(open)sidebar.removeAttribute('aria-hidden');
else sidebar.setAttribute('aria-hidden','true');
setSidebarFocusable(open);
}else{
sidebar.inert=false;
sidebar.removeAttribute('aria-hidden');
setSidebarFocusable(true);
}
}
setSidebarOpen(false);
toggle?.addEventListener('click',()=>setSidebarOpen(!sidebar?.classList.contains('open')));
document.addEventListener('click',(e)=>{if(!sidebar?.classList.contains('open'))return;if(sidebar.contains(e.target)||toggle?.contains(e.target))return;setSidebarOpen(false)});
document.addEventListener('keydown',(e)=>{if(e.key==='Escape')setSidebarOpen(false)});
const syncSidebarForViewport=()=>setSidebarOpen(sidebar?.classList.contains('open')??false);
if(mobileNav.addEventListener)mobileNav.addEventListener('change',syncSidebarForViewport);
else mobileNav.addListener?.(syncSidebarForViewport);
const input=document.getElementById('doc-search');
input?.addEventListener('input',()=>{const q=input.value.trim().toLowerCase();document.querySelectorAll('nav section').forEach(sec=>{let any=false;sec.querySelectorAll('.nav-link').forEach(a=>{const m=!q||a.textContent.toLowerCase().includes(q);a.style.display=m?'block':'none';if(m)any=true});sec.style.display=any?'block':'none'})});
function attachCopy(target,getText){const btn=document.createElement('button');btn.type='button';btn.className='copy';btn.textContent='Copy';btn.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(getText());btn.textContent='Copied';btn.classList.add('copied');setTimeout(()=>{btn.textContent='Copy';btn.classList.remove('copied')},1400)}catch{btn.textContent='Failed';setTimeout(()=>{btn.textContent='Copy'},1400)}});target.appendChild(btn)}
document.querySelectorAll('.doc pre').forEach(pre=>attachCopy(pre,()=>pre.querySelector('code')?.textContent??''));
document.querySelectorAll('.home-install').forEach(el=>attachCopy(el,()=>el.querySelector('code')?.textContent??''));
const tocLinks=document.querySelectorAll('.toc a');
if(tocLinks.length){const map=new Map();tocLinks.forEach(a=>{const id=a.getAttribute('href').slice(1);const el=document.getElementById(id);if(el)map.set(el,a)});const setActive=l=>{tocLinks.forEach(x=>x.classList.remove('active'));l.classList.add('active')};const obs=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);if(visible.length){const link=map.get(visible[0].target);if(link)setActive(link)}},{rootMargin:'-15% 0px -65% 0px',threshold:0});map.forEach((_,el)=>obs.observe(el))}
`;
}
export function preThemeScript() {
return `(function(){var s;try{s=localStorage.getItem('theme')}catch(e){}var d=window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.dataset.theme=s||(d?'dark':'light')})();`;
}
export function themeToggleHtml() {
return `<button class="theme-toggle" type="button" aria-label="Toggle dark mode" aria-pressed="false" data-theme-toggle>
<svg class="theme-icon-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M14.6 12.1A6.5 6.5 0 0 1 7.4 2.7a6.5 6.5 0 1 0 7.2 9.4z" fill="currentColor"/></svg>
<svg class="theme-icon-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="3.4" fill="currentColor"/><g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="10" y1="2" x2="10" y2="4"/><line x1="10" y1="16" x2="10" y2="18"/><line x1="2" y1="10" x2="4" y2="10"/><line x1="16" y1="10" x2="18" y2="10"/><line x1="4.2" y1="4.2" x2="5.6" y2="5.6"/><line x1="14.4" y1="14.4" x2="15.8" y2="15.8"/><line x1="4.2" y1="15.8" x2="5.6" y2="14.4"/><line x1="14.4" y1="5.6" x2="15.8" y2="4.2"/></g></svg>
</button>`;
}
export function faviconSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="songsee">
<rect width="64" height="64" rx="12" fill="#0c0e14"/>
<rect x="10" y="32" width="6" height="20" rx="2" fill="#6f4cff"/>
<rect x="20" y="22" width="6" height="30" rx="2" fill="#a05bd8"/>
<rect x="30" y="14" width="6" height="38" rx="2" fill="#e0438a"/>
<rect x="40" y="20" width="6" height="32" rx="2" fill="#ff8b3d"/>
<rect x="50" y="28" width="6" height="24" rx="2" fill="#36d3c4"/>
</svg>`;
}