Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507790169d | ||
|
|
0277e3e7bf | ||
|
|
11680218e7 | ||
|
|
5cee6499fe | ||
|
|
b177ce9d35 | ||
|
|
5c041de39a | ||
|
|
97cdef4634 | ||
|
|
6b24d31e99 | ||
|
|
f11e6e7e9b |
54
.github/workflows/pages.yml
vendored
Normal file
54
.github/workflows/pages.yml
vendored
Normal 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
|
||||
64
.github/workflows/update-homebrew-tap.yml
vendored
Normal file
64
.github/workflows/update-homebrew-tap.yml
vendored
Normal 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
4
.gitignore
vendored
@ -30,3 +30,7 @@ go.work.sum
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
|
||||
# Build output
|
||||
/bin/
|
||||
/dist/
|
||||
|
||||
@ -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
|
||||
|
||||
5
Makefile
5
Makefile
@ -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
|
||||
|
||||
@ -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
22
docs/RELEASING.md
Normal 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`).
|
||||
@ -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: {}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
93
docs/cli.md
Normal 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
78
docs/decoding.md
Normal 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.
|
||||
145
docs/index.md
145
docs/index.md
@ -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
68
docs/install.md
Normal 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
93
docs/palettes.md
Normal 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
74
docs/quickstart.md
Normal 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
110
docs/rendering.md
Normal 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 |
|
||||
| 5–6 | 3×2 |
|
||||
| 7–9 | 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.
|
||||
157
docs/spec.md
157
docs/spec.md
@ -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 5th–98th percentile |
|
||||
| `mel` | mel-warped power | log-magnitude; clamped 5th–98th percentile |
|
||||
| `chroma` | 12-bin pitch class | folds octaves; clamped 10th–98th 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 10th–98th percentile |
|
||||
| `loudness` | per-frame RMS | clamped to 95th percentile |
|
||||
| `tempogram` | onset autocorrelation | 30–240 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
120
docs/visualizations.md
Normal 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 30–240 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
708
scripts/build-docs-site.mjs
Normal 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(/<(https?:\/\/[^\s<>]+)>/g, '<a href="$1">$1</a>');
|
||||
out = out.replace(/\\\|/g, "|");
|
||||
out = out.replace(/<br>/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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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();
|
||||
}
|
||||
296
scripts/docs-site-assets.mjs
Normal file
296
scripts/docs-site-assets.mjs
Normal 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>`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user