v7.13 — merge the parallel v7.12 line into the OVE-Directory build

Three-way merge (base d59d398, verified identical trees). Brings in:
- 7.10.1: spectrum edge fix (SPEC_TOP_HZ 16 kHz cap)
- 7.10.2 (was 7.11 there): wake lock, respectful Demo, one-click Save
- 7.12: background movement (bgrot/bgx/bgy/bgzoom) + osc-rotations preset

Hand-merged: script-tab.tsx toolbar (one-click Save + Upload side by side),
version.ts + release-notes.ts single timeline, new 7.13 entries.
This commit is contained in:
Z User 2026-11-05 08:09:31 +00:00
commit b161685026
45 changed files with 4537 additions and 38 deletions

Binary file not shown.

View file

@ -15,6 +15,12 @@ use rustfft::{Fft, FftPlanner};
/// engine's AnalyserNode.
pub const FFT_SIZE: usize = 8192;
/// Top of the script-visible spectrum, in Hz. Mirrors SPEC_TOP_HZ in
/// src/lib/ava/audio.ts (v7.10.1): the bar field stops at 16 kHz instead of
/// Nyquist because music and every lossy codec are silent above that, which
/// used to freeze the right edge of spectrum visuals.
pub const SPEC_TOP_HZ: f32 = 16000.0;
/// Waveform history kept for the script `v` variable (matches the web
/// engine's "most recent 2048 samples" window).
pub const WAVE_SIZE: usize = 2048;
@ -48,13 +54,18 @@ impl Spectrum {
}
/// Spectrum magnitude at position 0..1 → 0..1 with the web engine's
/// log-ish frequency mapping. Mirrors `AudioEngine.getSpectrum()`.
/// log-ish frequency mapping. Mirrors `AudioEngine.getSpectrum()` in
/// audio.ts: the field tops out at `SPEC_TOP_HZ` (16 kHz), not Nyquist.
pub fn spectrum_at(&self, pos: f64) -> f64 {
if self.mags.is_empty() {
return 0.0;
}
let p = pos.clamp(0.0, 1.0);
let bin = 2.0 + (p.powf(1.6) * (self.mags.len() as f64 - 3.0)).floor();
let bh = bin_hz(self.sample_rate, self.mags.len() * 2);
let hi = ((SPEC_TOP_HZ / bh).ceil() as usize)
.min(self.mags.len() - 1)
.max(3);
let bin = 2.0 + (p.powf(1.6) * (hi as f64 - 2.0)).floor();
self.mags[(bin as usize).min(self.mags.len() - 1)] as f64
}
}

View file

@ -17,8 +17,9 @@ import { VisualStage } from '@/components/ava/visual-stage'
import { ControlPanel } from '@/components/ava/control-panel'
import { FooterBar } from '@/components/ava/footer-bar'
import { QueueBar } from '@/components/ava/queue-bar'
import { applyScriptText, tapBeat } from '@/components/ava/audio-source'
import { applyScriptText, setBootScriptSnapshot, tapBeat } from '@/components/ava/audio-source'
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
import { releaseWakeLock, syncWakeLock, watchWakeLockVisibility } from '@/lib/ava/wake-lock'
import {
lsGetString,
readStoredBandConfig,
@ -105,6 +106,7 @@ function useInitialLoad() {
// (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers
// every beat's hue) → any Zai-Magic preset → template
let text: string | null = lsGetString('ava.defaultScript')
const usedSavedDefault = text !== null && text.trim() !== ''
let startupPreset: (typeof presetsList)[number] | undefined
if (!text) {
startupPreset =
@ -117,6 +119,12 @@ function useInitialLoad() {
}
if (!text) text = STARTER_TEMPLATE
applyScriptText(text)
// v7.11: remember the untouched startup script — the Demo showcase
// only auto-engages while the editor still matches it (any difference
// is the visitor's own material). A SAVED default is the visitor's
// own material from the start, so it counts as their choice.
setBootScriptSnapshot(text)
if (usedSavedDefault) store.markManualChoice()
if (startupPreset) store.setActivePresetId(startupPreset.id)
// club mode: bring back the random-preset timer if it was on
@ -146,10 +154,28 @@ function useSpaceToTap() {
}, [])
}
/**
* v7.11 — keep the phone's screen on while music is playing. Any source but
* idle holds a screen wake lock (see lib/ava/wake-lock.ts); the moment the
* engine goes idle the lock is released and the device's normal inactivity
* dimming / sleep takes over again. Browsers silently drop the sentinel when
* the page is hidden, so a visibilitychange watcher re-acquires it on return
* while music is still going. Unsupported browsers degrade silently.
*/
function useWakeLockOnPlayback() {
const source = useAvaStore((s) => s.source)
useEffect(() => {
syncWakeLock(source !== 'idle')
}, [source])
useEffect(() => watchWakeLockVisibility(), [])
useEffect(() => () => releaseWakeLock(), [])
}
export default function Home() {
useInitialLoad()
useBeatWatcher()
useSpaceToTap()
useWakeLockOnPlayback()
return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden">

View file

@ -11,6 +11,7 @@ import { getEngine } from '@/lib/ava/engine'
import type { TapResult } from '@/lib/ava/beat'
import { validateScript } from '@/lib/ava/script'
import type { ScriptError } from '@/lib/ava/script'
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
import { DEMO_TRACK_COUNT, demoTrackTitle } from '@/lib/ava/audio'
import { useAvaStore } from '@/lib/store'
import { toast, useToast } from '@/hooks/use-toast'
@ -325,16 +326,56 @@ function fileErrorMessage(err: unknown): string {
/** The read-only folder that ships the hand-authored Zai-Magic presets. */
const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic'
// ---------------------------------------------------------------------------
// v7.11 — the visitor's own selection vs. the welcome showcase
// ---------------------------------------------------------------------------
/** The script text this visit booted with (saved default / Bass Bloom /
* template). Any later difference = the visitor's own material. */
let bootScriptSnapshot = ''
/** Capture the startup script (page boot) — the "untouched editor" baseline. */
export function setBootScriptSnapshot(text: string): void {
bootScriptSnapshot = text.trim()
}
/**
* v7.11 — does this visitor have their OWN thing going? True when they
* loaded a preset (tree Load / double-click / Copy to my presets / created
* one), brought their own script material into the editor (typed, pasted,
* hash-imported, file-loaded — anything that differs from the untouched
* startup text), saved their own default startup script, or are running
* their own mix mode. The Demo showcase auto-engages ONLY while this is
* false; otherwise the Demo button just plays music and touches nothing —
* a custom script can be tested and debugged against the demo track
* without any fear of it being replaced.
*/
export function visitorHasOwnSelection(): boolean {
const s = useAvaStore.getState()
if (s.manualChoice) return true
// their own club-mode mix is running — the demo must not re-scope it
if (s.randomize || s.sequenceMode) return true
const cur = s.scriptText.trim()
if (!cur) return false
// the untouched starter template (fresh visit / New button) is not own material
if (cur === STARTER_TEMPLATE.trim()) return false
// anything else that differs from the untouched startup text is theirs
return cur !== bootScriptSnapshot
}
/**
* Music just started but no preset is active (the visitor never picked one —
* e.g. they are running their own untitled draft): load the default preset,
* Zai-Magic "Bass Bloom", so something beat-reactive is on screen the moment
* audio arrives. Engine-only — the editor's unsaved text is never touched
* (the same courtesy random switching pays).
* (the same courtesy random switching pays). v7.11: a visitor with their OWN
* material (custom draft / saved default) is left completely alone — the
* forced default used to replace their running script.
*/
function ensureDefaultPreset(): void {
const s = useAvaStore.getState()
if (s.activePresetId !== null) return
if (visitorHasOwnSelection()) return
const preset = s.presets.find(
(p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom',
)
@ -353,7 +394,8 @@ function ensureDefaultPreset(): void {
* expand it so the visitor sees what is playing), then start the sequence —
* v7.4: every Zai-Magic preset IN ORDER, one every 20 seconds, wrapping
* around, for the one play-through (Sequence, not Random — the demo pass
* should walk the whole family, not repeat picks).
* should walk the whole family, not repeat picks). v7.11: only ever called
* for a visit that made no choice of their own (see visitorHasOwnSelection).
*/
function startShowcaseMix(): void {
const s = useAvaStore.getState()
@ -370,7 +412,8 @@ function startShowcaseMix(): void {
* Stop button, or switching to another source) unwind the showcase state:
* the Demo flow force-checked the mix mode (Sequence), so it gets unchecked
* again. The visitor's own selection (the last preset that was running)
* stays put.
* stays put. v7.11: only called when the showcase actually engaged — a
* visitor whose own Random/Sequence mix was already running keeps it.
*/
function retireDemoShowcase(): void {
const s = useAvaStore.getState()
@ -430,27 +473,37 @@ export function useAudioSource() {
const index = readDemoNext()
try {
await engine.audio.startDemo(index)
// v7.11: the welcome showcase (Bass Bloom + Zai-Magic Sequence) only
// engages for a visit that made NO choice of their own — no preset
// loaded (double-click / Load / a copy), no custom script material in
// the editor, no own mix running. Everyone else keeps their selection:
// Demo just plays the track.
const showcase = !visitorHasOwnSelection()
// v7.3/v7.10: single pass — when the track runs out (or is stopped, or
// replaced by another source) the engine fires onDemoEnded exactly
// once, WITH the reason: sync the store to idle (the landing overlay
// returns), uncheck the randomizer the showcase had checked, and —
// when the pass truly ENDED (natural finish or the Stop button) —
// returns), uncheck the showcase mix ONLY when the showcase engaged,
// and — when the pass truly ENDED (natural finish or the Stop button) —
// advance the collection place so the next Demo click plays the next
// track. A source switch keeps the visitor's place.
engine.audio.onDemoEnded = (reason) => {
syncSource()
retireDemoShowcase()
if (showcase) retireDemoShowcase()
if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1)
}
syncSource()
// showcase flow (v7.2/v7.4): nothing active → Bass Bloom, then the
// Zai-Magic sequence takes over — every preset in order, 20 s each,
// for this one play-through
// showcase flow (v7.2/v7.4/v7.11): nothing active and nothing of the
// visitor's own → Bass Bloom, then the Zai-Magic sequence takes over —
// every preset in order, 20 s each, for this one play-through. With
// the visitor's own selection, BOTH are skipped (ensureDefaultPreset
// holds its own guard as well).
ensureDefaultPreset()
startShowcaseMix()
if (showcase) startShowcaseMix()
toast({
title: 'Demo track playing',
description: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`,
description: showcase
? `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`
: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · your selection stays active -- the demo never touches your preset or the editor.`,
})
} catch (err) {
syncSource()

View file

@ -23,12 +23,15 @@ const FRAME_VARS: [string, string][] = [
['w / h', 'canvas size in pixels'],
['aspect', 'canvas width / height -- divide x by it for perfect circles'],
['persist', 'motion-trail persistence 0..0.98 (writable per frame)'],
['bgrot', 'background spin in radians per frame -- + clockwise, - counter-clockwise; 0 = off (set in [init] / [frame] / [beat]; persists like any variable)'],
['bgx / bgy', 'background drift per frame, the -1..1 screen convention (y is up) -- slide the whole picture up, down, left or right'],
['bgzoom', 'background scale per frame -- below 1 draw-in (shrinks toward the center), above 1 blow-out (expands); 1 = off'],
]
const POINT_VARS: [string, string][] = [
['i', '0..1 point index'],
['v', 'waveform sample −1..1'],
['sv', 'spectrum magnitude 0..1'],
['sv', 'spectrum magnitude 0..1 (≈12 Hz to 16 kHz, log-ish -- the range music actually occupies)'],
['x / y', 'position −1..1 (x = sin(a)*rad / aspect keeps circles round)'],
['red / green / blue', 'point color 0..1'],
['skip', '> 0.5 hides the point / breaks the line'],
@ -62,6 +65,9 @@ const EXAMPLES: [string, string][] = [
['rot = rot + dt * 1.2 + beat * 0.4', 'beat kick'],
['ang = rand(i) * 6.28318', 'stable per-point random'],
['x = sin(i * 6.28318 + t) * rad / aspect', 'circle'],
['bgrot = dir * (0.004 + exp(-beatage * 3) * 0.02)', 'the background spins to the music -- every beat accelerates it, then it relaxes (slow persist blends the trails)'],
['if(above(bass, bass_att * 1.25), dir = -dir, 0)', 'a hard hit flips the background spin direction -- put this in [beat]'],
['bgzoom = 1 - 0.001', 'draw-in -- the picture slowly spirals toward the center'],
]
function HelpTable({ rows, mono = true }: { rows: [string, string][]; mono?: boolean }) {

View file

@ -167,6 +167,9 @@ function useLoadPreset() {
if (result.ok) {
st.setActivePresetId(preset.id)
st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath })
// v7.11: an explicitly loaded preset (double-click / Load button) is
// the visitor's own choice — the Demo showcase must never override it
st.markManualChoice()
// No "Preset loaded" toast (v7.3): the footer's SCRIPT: label
// already echoes the running preset — the popup was intrusive and
// only compile errors genuinely need the attention grab.
@ -527,6 +530,7 @@ export function PresetsTab() {
applyScriptText(STARTER_TEMPLATE)
st.setActivePresetId(res.preset.id)
st.selectPreset(res.preset.id)
st.markManualChoice() // v7.11 — a created preset is the visitor's own choice
st.setTab('script')
toast({
title: 'Preset created',
@ -671,6 +675,7 @@ export function PresetsTab() {
const st = useAvaStore.getState()
st.toggleFolder(USER_ROOT, true)
st.selectPreset(res.preset.id)
st.markManualChoice() // v7.11 — a copied preset is the visitor's own choice
toast({
title: 'Copied to your presets',
description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`,

View file

@ -1,7 +1,9 @@
// AVA — Script tab: the custom visualization script editor.
//
// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save
// target is set), Save As…, Save as Default, Save to File, Load from File,
// Toolbar: Apply, Save (to the preset picked in the Presets tab when a save
// target is set; v7.11 also with NO target — one click stores the current
// script as a new preset under "user created", auto-named from its name:
// header), Save As…, Save as Default, Save to File, Load from File,
// Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
// string and paste one back), Upload to OVE-Directory (v7.11 — publish the
// compiled script to the community directory), New, Help. Editor: line-number
@ -51,7 +53,7 @@ import { sanitizeFileName } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths'
import { createUserPreset, presetIsLocal, updateUserPreset } from '@/lib/ava/user-presets'
import { createUserPreset, listUserPresets, presetIsLocal, renameUserPreset, updateUserPreset } from '@/lib/ava/user-presets'
/** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */
function formatSavePath(folderPath: string, name: string): string {
@ -61,6 +63,16 @@ function formatSavePath(folderPath: string, name: string): string {
return `${segs.slice(0, 2).join('/')}/…/${name}`
}
/** First free name in a folder: "Base", "Base (1)", "Base (2)" … */
function uniqueName(base: string, taken: Set<string>): string {
if (!taken.has(base)) return base
for (let i = 1; i < 1000; i++) {
const candidate = `${base} (${i})`
if (!taken.has(candidate)) return candidate
}
return `${base} (${Date.now().toString(36).slice(-4)})`
}
export function ScriptTab() {
const { toast } = useToast()
const scriptText = useAvaStore((s) => s.scriptText)
@ -89,6 +101,17 @@ export function ScriptTab() {
// earlier Apply keeps this off until Apply runs clean).
const [uploadOpen, setUploadOpen] = useState(false)
const canUpload = validation?.ok === true && !runtimeError
// v7.11 (parallel line, merged in v7.13) — the display name a Hash⇢Script
// import arrived with (the script's name: header, else the share-string
// label); the one-click Save uses it when the script itself carries no name
const [pendingName, setPendingName] = useState<string | null>(null)
// v7.11 (parallel line, merged in v7.13) — "Give This Preset a Name" dialog
// (renames the just-saved auto-named preset: nameless scripts / duplicates)
const [renameOpen, setRenameOpen] = useState(false)
const [renameValue, setRenameValue] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameBusy, setRenameBusy] = useState(false)
const [renameTarget, setRenameTarget] = useState<{ id: string; name: string; folderPath: string } | null>(null)
// debounced live validation (compile is cheap, scripts are small)
useEffect(() => {
@ -168,6 +191,111 @@ export function ScriptTab() {
}
}
/**
* v7.11 — one-click save with NO target picked: create a new preset under
* "user created" from the current editor script. Auto-named from the
* script's name: header (e.g. a hash import of "Spectrum Bars:[▼…]" saves
* as "Spectrum Bars"); a nameless script lands as "Empty Name", a
* conflict as "Base (1)", "Base (2)"… — and whenever the name had to be
* invented, the "Give This Preset a Name" dialog opens right after so the
* just-saved preset can be renamed in one extra step. Nothing is ever
* lost to a naming problem: the preset is saved FIRST, renamed second.
*/
const saveAsNewPreset = async () => {
if (saving) return
setSaving(true)
try {
const v = validateScript(scriptText)
if (!v.ok) {
toast({
title: 'Fix the compile errors first',
description: 'The script must compile before it can be saved as a preset.',
variant: 'destructive',
})
return
}
// candidate name: the script's own name: header, else the share-string
// label it arrived with (Hash⇢Script import)
let base: string | null = null
const metaName = v.meta?.name?.trim()
if (metaName && metaName !== 'Untitled') base = metaName
if (!base && pendingName) base = pendingName.trim() || null
if (base) {
// preset names must stay path-safe (no slashes / control characters)
base = base.replace(/[/\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120).trim()
if (!base) base = null
}
const taken = new Set(
listUserPresets().filter((p) => p.folderPath === USER_ROOT).map((p) => p.name),
)
const autoName = uniqueName(base ?? 'Empty Name', taken)
const invented = base === null || autoName !== base
const res = createUserPreset({
name: autoName,
content: scriptText,
folderPath: USER_ROOT,
author: v.meta?.author || 'you',
})
if (!res.ok) {
toast({ title: 'Save failed', description: res.error, variant: 'destructive' })
return
}
applyUserPresets()
setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
const st = useAvaStore.getState()
st.selectPreset(res.preset.id)
st.toggleFolder(USER_ROOT, true)
toast({
title: `Saved as "${res.preset.name}"`,
description: invented
? 'Stored under "user created" in this browser -- give it a better name in the dialog.'
: 'Stored under "user created" in this browser.',
})
if (invented) {
// the name had to be invented (nameless script / duplicate conflict)
// — the preset is SAFE; offer the rename as one extra step
setRenameTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
setRenameValue(base ?? '')
setRenameError(null)
setRenameOpen(true)
}
} finally {
setSaving(false)
}
}
/** v7.11 — confirm the "Give This Preset a Name" dialog: rename the
* just-saved preset to the visitor's name (stays open on conflicts). */
const applyRename = async () => {
if (!renameTarget || renameBusy) return
const trimmed = renameValue.trim()
if (!trimmed) {
setRenameError('Enter a name first.')
return
}
setRenameBusy(true)
try {
const res = renameUserPreset(renameTarget.id, trimmed)
if (!res.ok) {
setRenameError(res.error)
return
}
applyUserPresets()
const st = useAvaStore.getState()
if (st.editTarget?.id === res.preset.id) {
st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
}
st.selectPreset(res.preset.id)
setRenameOpen(false)
toast({
title: 'Preset renamed',
description: `Saved as "${res.preset.name}" in ${res.preset.folderPath} (this browser).`,
})
} finally {
setRenameBusy(false)
}
}
const openSaveAs = () => {
if (!editTarget) return
setSaveAsName(editTarget.name)
@ -235,6 +363,9 @@ export function ScriptTab() {
const text = await file.text()
const result = applyScriptText(text)
if (result.ok) {
// v7.11: a file from disk is the visitor's own material — the Demo
// showcase must never clobber it
useAvaStore.getState().markManualChoice()
const meta = validateScript(text).meta
toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` })
} else if (result.error) {
@ -310,7 +441,7 @@ export function ScriptTab() {
>
<Play className="size-3.5" /> Apply
</Button>
{editTarget && (
{editTarget ? (
<Button
size="sm"
onClick={() => void saveToTarget()}
@ -320,6 +451,18 @@ export function ScriptTab() {
>
<Save className="size-3.5" /> Save
</Button>
) : (
validation?.ok && (
<Button
size="sm"
onClick={() => void saveAsNewPreset()}
disabled={saving}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-emerald-950 hover:bg-emerald-300"
title='Save the current script as a NEW preset under "user created" -- auto-named from the script name: header'
>
<Save className="size-3.5" /> Save
</Button>
)
)}
{editTarget && (
<Button
@ -398,6 +541,7 @@ export function ScriptTab() {
onClick={() => {
setScriptText(STARTER_TEMPLATE)
setEditTarget(null)
setPendingName(null)
}}
className="h-9 gap-1.5 border-zinc-700"
title="Start over from the template (clears the save target)"
@ -510,12 +654,73 @@ export function ScriptTab() {
</DialogContent>
</Dialog>
{/* v7.11 -- rename the just-auto-named preset (nameless script /
duplicate conflict): the preset is already saved under "user
created"; this dialog only gives it the visitor's own name */}
<Dialog
open={renameOpen}
onOpenChange={(o) => {
setRenameOpen(o)
if (o) setRenameError(null)
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Give This Preset a Name</DialogTitle>
<DialogDescription>
The script is already saved as “{renameTarget?.name ?? 'Untitled'}” under “user created” in this
browser. Name it anything you like -- the rename applies instantly.
</DialogDescription>
</DialogHeader>
<div>
<Input
value={renameValue}
onChange={(e) => {
setRenameValue(e.target.value)
setRenameError(null)
}}
placeholder="Preset name"
autoFocus
aria-label="New preset name"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === 'Enter') void applyRename()
}}
/>
{renameError && <p className="mt-1.5 text-[11px] text-rose-400">{renameError}</p>}
</div>
<DialogFooter>
<Button variant="outline" className="h-9 border-zinc-700" onClick={() => setRenameOpen(false)}>
Keep as-is
</Button>
<Button
className="h-9 gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
disabled={renameBusy}
onClick={() => void applyRename()}
>
<Save className="size-3.5" /> {renameBusy ? 'Renaming…' : 'Save Name'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v7.7 -- share-string dialogs */}
<ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} />
<HashToScriptDialog
open={shareInOpen}
onOpenChange={setShareInOpen}
onImport={(text) => setScriptText(text)}
onImport={(text, name) => {
setScriptText(text)
setPendingName(name)
// the editor now holds FOREIGN content — the previous save target
// no longer matches what is in the editor, so a later Save must not
// overwrite it (v7.11: Save creates a new preset instead)
setEditTarget(null)
// imported material is the visitor's own — the Demo showcase must
// never clobber it (v7.11)
useAvaStore.getState().markManualChoice()
}}
/>
{/* v7.11 -- publish to the community directory */}

View file

@ -164,8 +164,10 @@ export function HashToScriptDialog({
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** Load the decoded script into the editor (script-tab wires this). */
onImport: (script: string) => void
/** Load the decoded script into the editor (script-tab wires this).
* v7.11: also hands back the script's display name (its name: header,
* else the share-string label) so Save can auto-name the new preset. */
onImport: (script: string, name: string | null) => void
}) {
const { toast } = useToast()
const [input, setInput] = useState('')
@ -219,12 +221,12 @@ export function HashToScriptDialog({
function load() {
if (!decoded) return
onImport(decoded.script)
onImport(decoded.script, decoded.name ?? null)
onOpenChange(false)
toast({
title: 'Script loaded from share string',
description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${
decoded.compiles ? ' Hit Apply to run it.' : ' Fix the errors, then Apply.'
decoded.compiles ? ' Hit Apply to run it, or Save to keep it as a preset.' : ' Fix the errors, then Apply.'
}`,
})
}

View file

@ -25,6 +25,19 @@ import type { AudioSourceKind, BandConfig, BandLevels } from './types'
import { ZERO_LEVELS } from './types'
const FFT_SIZE = 8192
/**
* Top of the script-visible spectrum, in Hz (v7.10.1). The bar field
* (getspec()/sv) tops out HERE instead of at Nyquist (22/24 kHz): real
* music carries almost nothing above ~16 kHz, and every lossy source
* low-passes even lower (192 kbps MP3 ≈ 18.6 kHz, YouTube AAC ≈ 15.5 kHz;
* measured across the demo collection: 0.000% of total magnitude above
* 18.6 kHz). A full-range mapping therefore left the rightmost ~20% of
* every spectrum visual frozen at zero -- bars that never moved. 16 kHz
* matches the spectral-flux cap in getBands(), and puts the right edge on
* the 12-16 kHz "air" band where cymbals, hats and synth sparkle live.
* Mirrored by SPEC_TOP_HZ in rust-agent/src/spectrum.rs.
*/
const SPEC_TOP_HZ = 16000
const ATTACK_TAU = 0.035 // seconds
const RELEASE_TAU = 0.22 // seconds
@ -375,11 +388,18 @@ export class AudioEngine {
return (this.timeData[idx] - 128) / 128
}
/** Spectrum magnitude at position 0..1 → 0..1 (log-ish frequency mapping). */
/**
* Spectrum magnitude at position 0..1 → 0..1 (log-ish frequency mapping).
* The field spans bin 2 (~12 Hz) to the SPEC_TOP_HZ bin (~16 kHz), so
* every position lands on frequencies music actually occupies -- see
* SPEC_TOP_HZ above for why it must not stretch to Nyquist.
*/
getSpectrum(pos: number): number {
if (this.freqData.length === 0 || this.kind === 'idle') return 0
const p = Math.min(1, Math.max(0, pos))
const bin = 2 + Math.floor(Math.pow(p, 1.6) * (this.freqData.length - 3))
const binHz = this.sampleRate / 2 / this.freqData.length
const hiBin = Math.max(3, Math.min(this.freqData.length - 1, Math.ceil(SPEC_TOP_HZ / binHz)))
const bin = 2 + Math.floor(Math.pow(p, 1.6) * (hiBin - 2))
return this.freqData[bin] / 255
}
}

View file

@ -7,16 +7,18 @@
// for originals that rely on effects the script engine cannot run.
//
// The "Zai-Magic" folder at the END of EMBEDDED_LIBRARY is the exception to
// "do not edit": ten presets HAND-AUTHORED for this engine (v7.00) to show
// off beat-synced color — they use hsvr/hsvg/hsvb, beatage, megabuf particle
// physics, getspec() and loop(). If the converter ever regenerates this
// file, keep that section verbatim.
// "do not edit": eleven presets HAND-AUTHORED for this engine (v7.00 + the
// v7.12 osc-rotations addition) to show off beat-synced color and the v7.12
// background transform -- they use hsvr/hsvg/hsvb, beatage, megabuf particle
// physics, getspec(), loop() and the bg* framebuffer controls. If the
// converter ever regenerates this file, keep that section verbatim.
//
// EMBEDDED_SEED_VERSION: bump whenever this library is regenerated — the
// GET /api/presets route re-seeds the root/embedded subtree when it changes
// (v7.10: em dashes in descriptions/script comments replaced with '--').
// (v7.10: em dashes in descriptions/script comments replaced with '--';
// v7.12: eleventh Zai-Magic preset "osc-rotations" added).
export const EMBEDDED_SEED_VERSION = '7.10'
export const EMBEDDED_SEED_VERSION = '7.12'
export interface EmbeddedPreset {
folder: string
@ -842,11 +844,13 @@ export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [
},
// ==========================================================================
// Zai-Magic — ten presets hand-authored for this engine (v7.00).
// Every one is built around BEAT-SYNCED COLOR: the color wheel turns on
// the kick, palettes swap per beat or per 4-beat bar, and kick envelopes
// (beatage) drive brightness. Techniques span all three render modes,
// megabuf particle physics, direct getspec() calls and loop() setup.
// Zai-Magic — eleven presets hand-authored for this engine (v7.00; the
// eleventh, "osc-rotations", shipped with v7.12). Every one is built around
// BEAT-SYNCED COLOR: the color wheel turns on the kick, palettes swap per
// beat or per 4-beat bar, and kick envelopes (beatage) drive brightness;
// osc-rotations adds the v7.12 background transform (bgrot/bgzoom) to the
// toolkit. Techniques span all three render modes, megabuf particle
// physics, direct getspec() calls and loop() setup.
// KEEP VERBATIM when regenerating this file with the converter.
// ==========================================================================
{
@ -1314,6 +1318,46 @@ red = hsvr(hue, sat, lum + flash * lit * 0.55)
green = hsvg(hue, sat, lum + flash * lit * 0.55)
blue = hsvb(hue, sat, lum + flash * lit * 0.55)
size = 2.4 + lit * 1.7 + treb * 1.2
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "osc-rotations",
author: "Zai",
description:
"A stationary centered oscilloscope over a background that rotates to the music -- every beat temporarily accelerates the spin, a hard hit flips it between clockwise and counter-clockwise, and the fade to black stays slow so the colors blend. The left and right points of the line color-shift together while the center points hold their own hue. Hand-authored for OVE (v7.12 background transform + mirror-symmetric hsv color).",
content: `name: "osc-rotations"
author: "Zai"
mode: lines
blend: additive
persist: 0.965
linewidth: 2.2
[init]
n = 512
hue = 0.58
dir = 1
[frame]
punch = exp(-beatage * 3.2)
bgrot = dir * (0.0032 + punch * (0.014 + vol_att * 0.012))
bgzoom = 1 - 0.0009 - punch * 0.0011
hue = frac(hue + dt * 0.012 + beat * 0.02)
[beat]
if(above(bass, bass_att * 1.25), dir = -dir, 0)
[point]
x = i * 2 - 1
y = v * (0.3 + vol_att * 0.8)
d = abs(i - 0.5) * 2
h = hue + d * 0.34
lum = 0.5 + 0.38 * vol_att + punch * 0.22
red = hsvr(h, 0.95, lum)
green = hsvg(h, 0.95, lum)
blue = hsvb(h, 0.95, lum)
size = 2 + punch * 1.6
alpha = 0.92
`,
},
]

View file

@ -16,6 +16,15 @@ import { DEFAULT_BAND_CONFIG, DEFAULT_BEAT_CONFIG } from './types'
const MAX_POINTS = 8192
const POINT_STRIDE = 8 // x, y, red, green, blue, skip, size, alpha
/** Read a script bg* control value. Non-finite (NaN / Infinity from a bad
* script computation) and missing values fall back to the neutral value --
* a NaN must never reach a canvas transform (it poisons the matrix and every
* later draw becomes a silent no-op: a black screen with no error). */
function bgNum(v: number | undefined, neutral: number): number {
const x = typeof v === 'number' ? v : neutral
return Number.isFinite(x) ? x : neutral
}
export interface EngineStats {
fps: number
levels: BandLevels
@ -54,6 +63,12 @@ export class AvaEngine {
/** ctx.filter support (older Safari lacks it → plain crossfade fallback). */
private canFilter = false
/** v7.12 background-feedback buffer. Same pixel size as the live canvas;
* recreated automatically on resize. Null until a script first asks for a
* background transform (zero cost for every preset that doesn't). */
private bgBuffer: HTMLCanvasElement | null = null
private bgBufCtx: CanvasRenderingContext2D | null = null
constructor() {
// AVS-eel compatibility hooks — the expression engine reads audio/time
// through these (getspec/getosc/gettime). Headless tests can override.
@ -141,6 +156,60 @@ export class AvaEngine {
return this.script?.meta.name ?? '--'
}
/**
* v7.12 — background feedback transform (the AVS movement family).
*
* The whole framebuffer is moved BEFORE this frame's points are drawn:
* 1. snapshot the live canvas into the offscreen buffer;
* 2. wipe the canvas black;
* 3. draw the snapshot back through the frame's transform.
*
* bgrot radians per frame — + rotates the picture CLOCKWISE on screen
* (canvas y is down, so ctx.rotate(+θ) spins cw); - = ccw.
* bgx/bgy drift per frame in the -1..1 screen convention (y is UP,
* matching script x/y) — slide up, down, left, right.
* bgzoom per-frame scale — <1 draw-in (shrinks toward center),
* >1 blow-out (expands outward); 1 = neutral.
*
* Uncovered regions (rotated corners, slid-off edges) read black, and the
* persistence fade (persist header / env.persist) blends the trails —
* which is exactly how the classic AVS plugins made "speed accelerates to
* the beat, a hard hit flips the spin, fade to black stays slow" work:
* the rate is just a script variable driven by beatage envelopes, and a
* [beat] section flips the sign on a hard hit (bass vs bass_att).
*/
private applyBackground(w: number, h: number, rot: number, tx: number, ty: number, zoom: number): void {
const canvas = this.canvas
const ctx = this.ctx
if (!canvas || !ctx) return
if (!this.bgBuffer || this.bgBuffer.width !== w || this.bgBuffer.height !== h) {
const buf = document.createElement('canvas')
buf.width = w
buf.height = h
this.bgBuffer = buf
this.bgBufCtx = buf.getContext('2d')
}
const bctx = this.bgBufCtx
if (!bctx || !this.bgBuffer) return
// 1. snapshot the live canvas ('copy' replaces the buffer wholesale)
bctx.globalCompositeOperation = 'copy'
bctx.drawImage(canvas, 0, 0)
// 2. wipe — the transformed snapshot must REPLACE the old content, not
// double-expose over it
ctx.globalCompositeOperation = 'source-over'
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, w, h)
// 3. draw the snapshot back through this frame's transform. Screen y is
// down; script y is up — bgy > 0 moves the picture UP.
ctx.save()
ctx.imageSmoothingEnabled = true
ctx.translate(w / 2 + tx * (w / 2), h / 2 - ty * (h / 2))
if (rot !== 0) ctx.rotate(rot)
if (zoom !== 1) ctx.scale(zoom, zoom)
ctx.drawImage(this.bgBuffer, -w / 2, -h / 2)
ctx.restore()
}
private resize(): void {
if (!this.canvas) return
const parent = this.canvas.parentElement
@ -227,6 +296,20 @@ export class AvaEngine {
} catch (err) {
this.scriptError = `runtime error: ${String(err)}`
}
// ---- background feedback transform (v7.12) --------------------------
// AVS-style whole-framebuffer movement, read AFTER [frame]/[beat] ran
// so this frame's values apply this frame. Set from [init] (persists
// forever), [frame] or [beat] (every frame / on the kick). Values live
// in env like any variable: a script that sets them only on beats gets
// on-beat jumps. Neutral readings skip the pass entirely.
const bgRot = Math.max(-0.6, Math.min(0.6, bgNum(env.bgrot, 0)))
const bgX = Math.max(-0.25, Math.min(0.25, bgNum(env.bgx, 0)))
const bgY = Math.max(-0.25, Math.min(0.25, bgNum(env.bgy, 0)))
const bgZoom = Math.max(0.5, Math.min(2, bgNum(env.bgzoom, 1)))
if (bgRot !== 0 || bgX !== 0 || bgY !== 0 || bgZoom !== 1) {
this.applyBackground(w, h, bgRot, bgX, bgY, bgZoom)
}
}
// ---- point evaluation ---------------------------------------------------

128
src/lib/ava/wake-lock.ts Normal file
View file

@ -0,0 +1,128 @@
// OVE — Screen Wake Lock manager (v7.11).
//
// Mobile browsers dim and eventually turn the screen off after a few seconds
// of inactivity — correct for reading, fatal for a music visualizer propped
// on a table. While audio is actually playing (demo / mic / file / link —
// anything but idle) the app holds a screen wake lock so the visuals stay
// visible; the moment playback stops the lock is released and the phone's
// normal inactivity timeout takes over again.
//
// API notes (developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API):
// • navigator.wakeLock.request('screen') → Promise<WakeLockSentinel>
// • The browser SILENTLY releases the sentinel when the page becomes
// hidden (app switch / screen lock) — so a 'visibilitychange' listener
// re-acquires it when the visitor comes back and music is still going.
// • request() rejects when unsupported (older iOS Safari, Firefox Android
// pre-126), when the document is hidden, or when a platform setting
// refuses — every rejection is swallowed: the lock is a nice-to-have,
// never a blocker.
// • Wake locks are free — no battery drain beyond the screen itself, and
// the page already renders at full tilt while playing.
/** Minimal structural type — lib.dom versions vary in Wake Lock coverage. */
interface WakeLockSentinelLike {
readonly released: boolean
release: () => Promise<void>
addEventListener: (type: 'release', listener: () => void) => void
removeEventListener: (type: 'release', listener: () => void) => void
}
interface WakeLockLike {
request: (type: 'screen') => Promise<WakeLockSentinelLike>
}
/** The held sentinel (null = not held / browser released it). */
let sentinel: WakeLockSentinelLike | null = null
/** Whether a lock is currently WANTED (music playing). */
let wantActive = false
/** Guards against overlapping request() calls. */
let requesting = false
/** The sentinel's own release listener (kept so it can be detached). */
let releaseListener: (() => void) | null = null
function navigatorWakeLock(): WakeLockLike | null {
if (typeof navigator === 'undefined') return null
const nav = navigator as Navigator & { wakeLock?: unknown }
const wl = nav.wakeLock
if (!wl || typeof wl !== 'object') return null
const typed = wl as Partial<WakeLockLike>
return typeof typed.request === 'function' ? (typed as WakeLockLike) : null
}
/** Drop our reference when the browser auto-releases (page hidden). */
function onSentinelRelease(): void {
sentinel = null
releaseListener = null
}
async function acquire(): Promise<void> {
if (sentinel !== null || requesting) return
const wl = navigatorWakeLock()
if (!wl) return
requesting = true
try {
const s = await wl.request('screen')
// state may have flipped to "stopped" while the promise was in flight
if (!wantActive) {
try {
await s.release()
} catch {
/* already gone */
}
return
}
sentinel = s
releaseListener = onSentinelRelease
s.addEventListener('release', releaseListener)
} catch {
/* unsupported / hidden / refused — degrade silently */
} finally {
requesting = false
}
}
function dropSentinel(): void {
const s = sentinel
sentinel = null
if (s) {
if (releaseListener) s.removeEventListener('release', releaseListener)
releaseListener = null
s.release().catch(() => {
/* already released */
})
}
}
/**
* Hold or release the screen wake lock according to playback. Called with
* `true` whenever a source starts, `false` whenever the engine goes idle.
*/
export function syncWakeLock(active: boolean): void {
wantActive = active
if (!active) {
dropSentinel()
return
}
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void acquire()
}
// hidden right now (rare — playback needs a gesture, but a queued playlist
// could land here): the visibilitychange listener below acquires on return.
}
/** Explicit teardown (hook unmount / pagehide). */
export function releaseWakeLock(): void {
syncWakeLock(false)
}
/**
* Re-acquire the lock when the page becomes visible again while music is
* still playing (the browser always drops it on hide). Returns a cleanup.
*/
export function watchWakeLockVisibility(): () => void {
const onVisibility = () => {
if (document.visibilityState === 'visible' && wantActive) void acquire()
}
document.addEventListener('visibilitychange', onVisibility)
return () => document.removeEventListener('visibilitychange', onVisibility)
}

View file

@ -39,6 +39,34 @@ export interface ReleaseNote {
}
export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.13',
name: 'One build, every feature',
changes: [
{
kind: 'improved',
text: 'The two development lines are merged into one build. Everything from the parallel line -- the unfrozen spectrum edge, the screen wake lock, the respectful Demo button, one-click saving, the background movement family and the "osc-rotations" preset -- now lives alongside the community OVE-Directory in this release.',
},
{
kind: 'improved',
text: 'The Script tab toolbar carries the one-click Save and "Upload to OVE-Directory" side by side, and the release list below now reads as one timeline (the parallel line\u2019s wake-lock release is folded in as 7.10.2).',
},
],
},
{
version: '7.12',
name: 'Background movement (AVS-style) + osc-rotations',
changes: [
{
kind: 'added',
text: 'Scripts can move the whole picture now: bgrot spins the background clockwise or counter-clockwise, bgx / bgy slide it up, down, left or right, and bgzoom draws it in or blows it out -- the movement family the classic Winamp AVS plugins were built on. The speed can ride the music: accelerate on every beat, flip the spin direction on a hard hit, and let a slow fade to black blend the colors. All four variables are documented in the script editor\'s help panel.',
},
{
kind: 'added',
text: 'I added an eleventh Zai-Magic preset: "osc-rotations" -- a stationary centered oscilloscope whose left and right points color-shift together while the center points hold their own hue, over a background that rotates clockwise and counter-clockwise to the music and temporarily accelerates with the beat. Hard hits flip the spin; the fade stays slow so the colors blend.',
},
],
},
{
version: '7.11',
name: 'The community OVE-Directory',
@ -61,6 +89,34 @@ export const RELEASE_NOTES: ReleaseNote[] = [
},
],
},
{
version: '7.10.2',
name: 'Wake lock, a respectful Demo, one-click save',
changes: [
{
kind: 'added',
text: 'The screen stays on while music plays: on phones and tablets the site now holds a screen wake lock for as long as any audio source is live, then lets go the moment playback stops so the device\'s normal screen timeout takes over again.',
},
{
kind: 'improved',
text: 'The Demo Track button stopped hijacking the preset selection. When a preset is already loaded (tree double-click, Load, a copied preset) or the editor holds custom script material, Demo just plays the track and leaves everything untouched -- the Zai-Magic Sequence showcase now only starts for a visit that never picked or typed anything, so a custom script can be built and debugged against the demo track without fear of it being replaced.',
},
{
kind: 'improved',
text: 'One-click saving in the Script tab: a valid script shows the Save button even with no save target picked, and one click stores it under "user created", auto-named from the script\'s name header (a hash import of "Spectrum Bars:[▼…]" saves as "Spectrum Bars"). Nameless scripts land as "Empty Name", collisions as "(1)", "(2)"… -- and in those cases a "Give This Preset a Name" dialog opens immediately so the just-saved preset can be renamed on the spot.',
},
],
},
{
version: '7.10.1',
name: 'Spectrum edge unfrozen',
changes: [
{
kind: 'fixed',
text: 'Spectrum visuals used to sit dead along the right edge: the bar field stretched all the way to the top of the FFT (~24 kHz), where music carries almost nothing and MP3/YouTube audio is already cut off, so the rightmost bars read permanent silence no matter how loud the track got. I capped the field at 16 kHz -- the range where music actually lives -- so every bar has something to react to.',
},
],
},
{
version: '7.10',
name: 'Ten demo tracks',

View file

@ -187,6 +187,14 @@ interface AvaState {
editTarget: EditTarget | null
/** Preset currently running on the engine (tree indicator). */
activePresetId: string | null
/** v7.11 — true once THIS visit made an explicit choice: loaded a preset
* (tree Load / double-click / copy-to-my-presets / created one), brought
* the visitor's own script material into the editor (typed / pasted /
* hash-imported / file-loaded), saved a default startup script, or runs
* their own mix mode. While false the Demo button may still run the
* welcome showcase; once true, Demo never touches the selection.
* Deliberately NOT persisted — every visit starts fresh. */
manualChoice: boolean
/** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence
* (in-order cycling with wrap-around). The two are mutually exclusive —
* ticking one unticks the other; both share one delay + folder scope and
@ -219,6 +227,8 @@ interface AvaState {
selectPreset: (id: string | null) => void
setEditTarget: (t: EditTarget | null) => void
setActivePresetId: (id: string | null) => void
/** v7.11 — flag this visitor as having made their own choice (see field). */
markManualChoice: () => void
setRandomize: (on: boolean) => void
/** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */
setSequenceMode: (on: boolean) => void
@ -371,6 +381,7 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
expandedFolders: {},
editTarget: null,
activePresetId: null,
manualChoice: false,
randomize: false,
sequenceMode: false,
randomIntervalSec: 10,
@ -481,6 +492,8 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
setActivePresetId: (id) => set({ activePresetId: id }),
markManualChoice: () => set({ manualChoice: true }),
setRandomize: (on) => {
// v7.4 mutual exclusion: ticking Random unticks Sequence (turning one
// OFF never lights the other — the visitor's explicit choice)

View file

@ -12,6 +12,53 @@
// warns in the console when the two drift apart.
//
// History:
// 7.13 — one build, every feature: the parallel development lines are
// merged. This tree now carries BOTH the community OVE-Directory
// (v7.11 here) AND the parallel line's additions — the spectrum
// 16 kHz cap (its 7.10.1), the screen wake lock + respectful
// Demo button + one-click Save (its 7.11, folded into this
// history as 7.10.2 to keep one clean timeline), and the
// AVS-style background movement family (bgrot / bgx / bgy /
// bgzoom) with the eleventh Zai-Magic preset "osc-rotations"
// (its 7.12). Git three-way merge against the shared v7.10
// ancestor (identical tree hashes verified on both sides); the
// only hand-merged surface was the Script tab toolbar, which
// now carries the one-click Save and "Upload to OVE-Directory"
// side by side, and the two changelogs (here + release-notes).
// 7.12 — background movement + osc-rotations. Scripts can now move the
// whole framebuffer every frame, the way the classic Winamp AVS
// plugins did: bgrot (radians/frame, + clockwise on screen),
// bgx / bgy (per-frame drift in the -1..1 screen convention, y
// up), bgzoom (< 1 draw-in, > 1 blow-out) -- read after [frame]/
// [beat] so this frame's values apply this frame. The engine
// snapshots the canvas into an offscreen buffer, wipes, and
// draws the snapshot back through the transform, so the
// persistence fade blends the moving trails: "speed accelerates
// to the beat, a hard hit flips the spin, fade to black stays
// slow" is pure script (a beatage envelope drives the rate; a
// [beat] section flips a dir variable on hard hits via bass vs
// bass_att). Neutral readings (0/0/0/1) skip the pass entirely
// (zero cost for every preset that doesn't move); non-finite
// values fall back to neutral (a NaN in a canvas transform
// matrix would black the screen with no error); per-frame
// clamps: rot ±0.6 rad, drift ±0.25, zoom 0.5..2. New 11th
// Zai-Magic preset "osc-rotations" (user-requested): a
// stationary centered oscilloscope (time-domain v) whose color
// is mirror-symmetric -- points equidistant left/right of
// center shift together, the center points hold their own hue
// -- over a background rotating cw/ccw to the music that
// temporarily accelerates on the beat; hard hits
// (bass > 1.25 × bass_att) flip the spin; a whisper of draw-in
// (bgzoom ~0.999) spirals the trails inward; persist 0.965
// keeps the fade slow so colors blend. Help panel: bg* variable
// rows + three new example formulas. EMBEDDED_SEED_VERSION
// 7.10 -> 7.12 (library re-seed adds the preset; the share
// codec's dictionary stays frozen per its compatibility
// contract -- bg* identifiers ride the pair packing like any
// custom variable). Headless validation of the preset's
// compiled programs: quiet 0.004 rad/frame, hard beat 0.026
// (6.5×) + direction flip, soft beat no flip, mirrored points
// share hue / center distinct
// 7.11 — the community OVE-Directory. A sixth tab (Directory) lists every
// script published to the community directory — a separate VPS at
// s3374dir.openvisengine.com, reached ONLY through this server's
@ -32,6 +79,47 @@
// "(1) (2) (3)…" on collision) and offers "Open in editor".
// Duplicate uploads resolve as success (409 DUPLICATE_HASH carries
// the existing row); duplicate votes show the stored aggregates.
// 7.10.2 — wake lock, a respectful Demo button, one-click save. While any
// audio source is live the site holds a Screen Wake Lock so phone
// and tablet screens stay on (released the moment the engine goes
// idle so the normal inactivity dimming returns; browsers silently
// drop the sentinel on hide, so a visibilitychange watcher
// re-acquires it on return while music is still going;
// unsupported browsers degrade silently). The Demo Track button no
// longer force-engages the Sequence checkbox: a visitor who loaded
// a preset (tree double-click / Load / Copy to my presets / created
// one), brought their own script material into the editor (typed,
// pasted, Hash⇢Script, file), saved their own default startup
// script, or runs their own Random/Sequence mix keeps their
// selection -- Demo just plays the track (the showcase's tree
// re-scope, 20 s interval and retire-on-end all skip too; a new
// per-visit manualChoice flag + a boot-script snapshot decide, and
// ensureDefaultPreset gained the same guard so mic/file/link never
// clobber a custom draft either; the showcase now only starts for
// a visit that never picked or typed anything). Script tab: a valid
// script shows the Save button with NO save target needed -- one
// click creates a preset under "user created", auto-named from the
// script's name: header (a hash import of "Spectrum Bars:[▼…]"
// saves as "Spectrum Bars"), "Empty Name" when nameless, " (1)"/
// " (2)"… suffixes on conflicts, and whenever the name had to be
// invented a "Give This Preset a Name" dialog opens to rename the
// just-saved preset in one step (the preset is always saved FIRST,
// renamed second, so nothing is ever lost to a naming problem).
// Hash⇢Script imports clear the stale save target (the editor now
// holds foreign content) and pass the decoded label through as the
// save-name fallback when the script has no name: header
// 7.10.1 — the frozen right edge of spectrum visuals. getspec()/sv mapped
// the bar field across the whole FFT (bin 2 ... Nyquist, ~12 Hz to
// 24 kHz), but music carries almost nothing above ~16 kHz and every
// lossy source low-passes even lower (192 kbps MP3 at ~18.6 kHz,
// YouTube AAC at ~15.5 kHz; measured across the demo collection:
// 0.000% of total magnitude above 18.6 kHz), so the rightmost ~20%
// of every spectrum visual read a permanent zero -- bars that never
// moved. getSpectrum() now tops the field out at 16 kHz
// (SPEC_TOP_HZ, matching the spectral-flux cap in getBands()), so
// the right edge lands on the 12-16 kHz "air" band where cymbals,
// hats and synth sparkle live. rust-agent's spectrum_at() mirrors
// the change, and the help panel's sv entry now states the span.
// 7.10 — ten demo tracks + first-person changelog + declutter. The demo
// source is now a COLLECTION: ten mastered tracks in
// public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename order
@ -319,4 +407,4 @@
export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.11'
export const APP_VERSION = '7.13'

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View file

@ -2213,3 +2213,82 @@ BROWSER E2E (agent-browser + VLM, ALL PASS):
Stage Summary:
- v7.7 shipped: scripts are now shareable as single Unicode strings — ~half the length of the script, self-checking, copy/paste-survivable, lossless for ANY text (compiling not required) — and the Settings tab is decluttered while the house account keeps Stream-a-Link working for everyone. The shared dialog height cap protects every dialog app-wide from tall content on short screens.
---
Task ID: 37
Agent: main (Z.ai Code)
Task: v7.10.1 (user report while testing their new "Spectrum Bars" script): a section on the very right of spectrum visuals is inactive and never reacts to the music -- same dead right edge on other spectrum scripts. Explain why, then fix it engine-side so every script benefits.
DIAGNOSIS (why the right edge is dead):
- getspec()/sv mapped bar position 0..1 across the WHOLE FFT: bin = 2 + floor(p^1.6 * 4093) at 8192-point FFT, so pos 1.0 = bin 4095 = Nyquist (22.05/24 kHz). pos 0.8 already lands at ~16.8 kHz.
- Real music carries almost nothing up there, and every lossy source low-passes even lower: the demo collection is 192 kbps MP3 @48 kHz (LAME low-pass ~18.6 kHz), YouTube AAC ~15.5 kHz. Measured on demo-01 (20 s, 8192-FFT): 0.000% of total magnitude above 18.6 kHz; content above -40 dB rel. peak ends at ~13.4 kHz, above -60 dB at ~17.8 kHz.
- So bar positions above ~0.82 read byte 0 every frame -- the rightmost ~20% of every spectrum visual was permanently frozen at the floor. Not a script bug: every script sampling getspec over 0..1 (all 10 converted getspec presets included) showed the same dead edge, exactly like the Winamp AVS originals. The engine's own flux detector already caps at 16 kHz for the same reason (audio.ts getBands).
Work Log:
- src/lib/ava/audio.ts: new SPEC_TOP_HZ = 16000 const (with the full why-comment); getSpectrum() now maps the field from bin 2 (~12 Hz) to the 16 kHz bin instead of Nyquist: hiBin = clamp(ceil(16000/binHz)), bin = 2 + floor(p^1.6 * (hiBin - 2)). Same log-ish curve, every position now lands on frequencies music occupies; right edge = 12-16 kHz "air" band (cymbals/hats/sparkle). Matches the flux cap.
- rust-agent/src/spectrum.rs: SPEC_TOP_HZ const + spectrum_at() mirrors the new mapping exactly (source parity; not compiled locally, no cargo).
- help-panel.tsx: sv entry now states the span: "spectrum magnitude 0..1 (≈12 Hz to 16 kHz, log-ish -- the range music actually occupies)".
- version.ts 7.10.1 + history entry; release-notes.ts 7.10.1 "Spectrum edge unfrozen" (fixed, first-person voice). lint clean; tsc clean apart from the pre-existing base-target share-codec BigInt literals.
VERIFICATION (three independent measurements):
- Offline full-track emulation (Blackman window + AnalyserNode dB byte mapping, every 21 ms of demo-01): under the NEW mapping every one of 48 bars is >15/255 in 76-100% of frames and >60/255 in 68-99% -- nothing is structurally dead. Under the old mapping positions >0.82 read literal zero.
- Fresh in-page chain (eval: fetch demo-01 -> decodeAudioData -> own AnalyserNode at t=150 s): top positions dance (pos 1.0 = 16 kHz fluctuates 4..118/255 frame to frame).
- Live E2E with the USER'S OWN "Spectrum Bars" script (found in the test browser's localStorage from the previous session, 28 bars x 4 pts, running on Demo Track 2 at 60 FPS): per-bar pixel measurements across 6 frames show the rightmost bars (f 0.74-1.0 = 9.9-16 kHz) swinging 4%..76% with the music -- under the old mapping those bars sampled 16.8-24 kHz and sat pinned at 0 (the script's own below(lvl,0.004)?0 snap would hold them perfectly flat). Also verified with a purpose-built 48-bar "Edge Check" script earlier in the session.
- Anomaly chased and closed: one 5-sample window showed bars 36-48 at floor while bass danced -- turned out to be the demo track's outro (offline analysis: t=187-191 s has a legitimately dead 10-16 kHz band while lows keep going); the demo ended right after (SRC: IDLE, place advanced), matching the timeline. Not a mapping issue.
- What's New dialog: v7.10.1 "Spectrum edge unfrozen" first with CURRENT badge + FIXED tag; drift guard silent after a clean reload (one transient guard warning was hot-reload event ordering + stale console history); the one hydration error was agent-browser's own data-__ab-ci instrumentation, absent on a clean reload. Console otherwise clean; dev.log clean.
- Test-browser localStorage cleared afterwards (convention); scratch PCM deleted.
Stage Summary:
- v7.10.1 shipped: the frozen right edge of every spectrum visual is unfrozen. The bar field now spans ~12 Hz to 16 kHz (the range music actually occupies) instead of stretching to Nyquist, so the right bars land on the 10-16 kHz air band and dance with the hats/cymbals. The user's own Spectrum Bars script (whose 0.78+0.45*f treble tilt now has something real to boost) verified live: rightmost bars swing 4..76%.
---
Task ID: 38
Agent: main (Z.ai Code)
Task: v7.11 (user request, three parts). RESTORED RETROACTIVELY — the entry was lost in a session cutoff (same as Task 35 once was); scope reconstructed from the version.ts 7.11 history and the tool-results/v711-* evidence screenshots (v711-01 through v711-22, all PASS). (1) Mobile wake lock: while any audio source is live the site holds a Screen Wake Lock so phone/tablet screens stay on; released the moment the engine goes idle so normal inactivity dimming returns. (2) A respectful Demo Track button: no longer force-engages the Sequence checkbox when the visitor has loaded a preset (tree double-click / Load / copied preset) or brought their own script material into the editor — Demo just plays the track; the Zai-Magic showcase only starts for a visit that never picked or typed anything. (3) Script tab one-click Save: a valid script shows the Save button with no save target needed; auto-names from the script's name header (a hash import of "Spectrum Bars:[▼…]" saves as "Spectrum Bars"), "Empty Name" when nameless, " (1)"/" (2)"… suffixes on conflicts — and when the name had to be invented, a "Give This Preset a Name" dialog opens to rename the just-saved preset in one step (saved FIRST, renamed second, so nothing is ever lost).
Work Log (reconstructed):
- src/lib/ava/wake-lock.ts (NEW): Screen Wake Lock sentinel manager — acquire on source-live, release on idle, visibilitychange re-acquire (browsers silently drop the sentinel on hide), silent degradation where unsupported.
- audio-source.ts / control-panel / page.tsx: per-visit manualChoice flag + boot-script snapshot decide whether the Demo showcase may engage; ensureDefaultPreset gained the same guard (mic/file/link never clobber a custom draft either). Demo teardown paths (ended/stop/switch) all respect it.
- script-tab.tsx: Save button visible on any valid script with no target; auto-naming ladder (script name header -> hash import label -> "Empty Name" -> conflict suffixes); "Give This Preset a Name" dialog after any invented save. Hash⇢Script imports clear the stale save target and pass the decoded label through as the fallback name.
- version.ts 7.11 + history; release-notes.ts 7.11 entry.
E2E (evidence: tool-results/v711-01..22, ALL PASS):
- Fresh boot (no selection, no editor material): Demo engages the sequence showcase as before — v711-19/20.
- Preset loaded first (double-click, v711-03; embedded, v711-04): Demo plays the track, Sequence stays UNCHECKED (v711-06), the running script is untouched — a custom script can be debugged against the demo track without fear of replacement (v711-05 custom material case).
- Hash⇢Script -> Save: lands under "user created" as "Spectrum Bars" from the share-string label (v711-09/10/11); nameless script saves as "Empty Name" then the rename dialog appears (v711-12/15); duplicate conflicts get " (1)"/" (2)" suffixes (v711-16/17) and the rename updates the saved preset (v711-13).
- Default boot script + reload states (v711-18); mobile 375 px (v711-21) and desktop (v711-22) clean; wake lock verified by code path (headless browsers do not expose navigator.wakeLock — degrade-silent branch exercised).
Stage Summary:
- v7.11 shipped: the screen stays on while music plays, the Demo button stopped hijacking the preset selection, and one click saves any valid script with a smart name. This restored entry closes the gap in the build diary.
---
Task ID: 39
Agent: main (Z.ai Code)
Task: v7.12 (user request): add the classic-AVS background-movement family to the scripting — the background shifts up/down/left/right, spins clockwise/counter-clockwise, draws in and blows out, with the speed accelerating to the beat and hard hits flipping the spin direction while the fade to black stays slow (so colors blend) — then ship a permanent Zai-Magic preset "osc-rotations": a stationary centered oscilloscope whose left/right points color-shift together and whose center points hold their own hue, over a background rotating cw/ccw to the music with temporary beat acceleration.
Work Log:
- src/lib/ava/engine.ts — NEW background feedback transform (the AVS movement family):
* Script controls, read from env AFTER [frame]/[beat] run so this frame's values apply this frame: bgrot (radians/frame, + = CLOCKWISE on screen — canvas y is down so ctx.rotate(+) spins cw), bgx/bgy (per-frame drift, the -1..1 screen convention, y is UP matching script x/y), bgzoom (per-frame scale: <1 draw-in, >1 blow-out, 1 = neutral). Values persist in env like any variable — set from [init] (persists forever), [frame] (every frame) or [beat] (on-beat jumps); neutral readings skip the pass entirely (zero cost for every preset that doesn't move).
* applyBackground(): snapshot the live canvas into an offscreen buffer (globalCompositeOperation 'copy'), wipe the canvas black (the transformed snapshot must REPLACE the old content, not double-expose), then draw the snapshot back through the frame's transform (translate w/2+bgx*w/2, h/2-bgy*h/2; rotate; scale). Uncovered regions (rotated corners, slid-off edges) read black and the persistence fade blends the trails — exactly how the classic AVS plugins made "speed accelerates to the beat, a hard hit flips the spin, fade stays slow" work: the rate is just a script variable driven by a beatage envelope, and a [beat] section flips a dir variable on hard hits (bass vs bass_att).
* Guards: bgNum() falls back to neutral for missing/NaN values (a NaN in a canvas transform matrix would black the screen with no error — every later draw becomes a silent no-op); per-frame clamps rot ±0.6 rad, drift ±0.25, zoom 0.5..2. Buffer recreated automatically on resize; null until first use (zero cost otherwise). Gated on this.script so a stale env after clearScript can never spin a blank canvas.
- src/lib/ava/embedded-presets.ts — eleventh Zai-Magic preset "osc-rotations" (user-named, lowercase so it sorts last in the tree): persist 0.965 (slow fade — colors blend), [frame] drives bgrot = dir * (0.0032 + punch * (0.014 + vol_att * 0.012)) with punch = exp(-beatage*3.2) (temporary beat acceleration that relaxes with the kick ring), bgzoom = 1 - 0.0009 - punch*0.0011 (a whisper of draw-in so trails spiral inward instead of ringing), hue drifts dt*0.012 + beat*0.02; [beat] flips dir on hard hits only (bass > 1.25 * bass_att — soft beats leave the spin alone); [point] draws the stationary centered scope x = i*2-1, y = v*(0.3+vol_att*0.8) with mirror-symmetric color h = hue + |i-0.5|*2 * 0.34 (points equidistant left/right of center share one hue, the center holds its own). EMBEDDED_SEED_VERSION 7.10 -> 7.12 (route re-seeds the subtree; fingerprint self-heal would have caught it too). "ten presets" comments updated to eleven (both the file-top and the Zai-Magic section header).
- src/components/ava/help-panel.tsx — bgrot / bgx/bgy / bgzoom rows in the variables table + three example formulas (beat-accelerated spin, hard-hit direction flip in [beat], draw-in).
- src/lib/version.ts 7.12 + history; src/lib/release-notes.ts 7.12 entry (2x added, first-person voice). Share codec deliberately untouched: its dictionary is a frozen compatibility contract (adding entries needs a new scheme marker) — bg* identifiers ride the pair packing like any custom variable.
HEADLESS VALIDATION (/home/z/tmp-validate/bg-transform/validate-osc.ts, ALL PASS):
- osc-rotations compiles; all 101 embedded presets still compile (regression guard).
- Compiled-program behavior: quiet frame bgrot 0.00397 rad/frame cw + bgzoom 0.99906; hard beat bgrot 0.0256 (6.5x) AND dir flips to -1; soft beat no flip; point pass x spans ±1 with |y| symmetric, mirrored positions share hue exactly (0.7906 both) while center is distinct (0.6206).
BROWSER E2E (agent-browser + pixel measurement + VLM, ALL PASS):
- Fresh boot: title "OVE -- Open Visualization Engine (v7.12)"; tree shows "Zai-Magic -- 11"; /api/presets re-seeded on the version gate and serves osc-rotations (106 presets total; sorts last in Zai-Magic as designed).
- Preset live on the demo collection (manual load first, then Demo Track — v7.11's respectful-Demo still holds: Random/Sequence stayed UNCHECKED with a preset loaded): footer "SCRIPT: osc-rotations · Demo Track 1/2 · FPS 59-60 · PTS 512 · BPM 132.3/138.8 locked". Mid-band (scope zone) mean 133/255; outer zones lit only by rotation-carried trails.
- bgrot isolation (line mathematically confined to the middle 35% band, so outer-zone content can ONLY be rotation-carried): outer zones hold a steady mean ~14/255 vs EXACTLY 0.00 in the no-bg control — rotation puts content where the line cannot reach.
- bgx sign: +0.012 drifts the bright cloud right (mean x 0.518); -0.012 mirrors it exactly (0.480) — the sign convention verified by symmetry.
- bgy sign: +0.02 pulls the bright cloud UP (mean y-from-top 0.368 vs 0.5 neutral).
- bgzoom: at 1.05/frame the outer zones went from dead 0.00 to ~38/255 (blow-out pushes the confined line's trails outward); at 1.003/frame with persist 0.92 trails die before traveling far (consistent with the fade math — equilibrium analysis, not a defect).
- What's New dialog: 7.12 "Background movement (AVS-style) + osc-rotations" first with CURRENT badge, drift guard silent. Help panel: bg* rows + examples all present.
- VLM on the live screenshot: horizontal oscillating waveform line at vertical center; large soft colorful trail masses around it; hue variation green at the outer left/right edges, yellow mid, red/pink at the center — the mirror-symmetric color design visibly realized; no defects; "60 FPS . 512 PTS" overlay normal.
- Console clean (0 errors/0 warnings); dev.log clean (59 successful requests, no compile errors). Footer: sticks to the viewport bottom on short pages (1280x800 Beat tab: docH 800, footer bottom 800) and pushes to the document end on long pages (375 px Presets tab: docH 1469, footer at doc end, no overlap); 375 px scrollWidth 375 = clientWidth (no horizontal overflow). Test-browser localStorage cleared afterwards.
Stage Summary:
- v7.12 shipped: scripts can move the whole picture — bgrot spins, bgx/bgy slide, bgzoom draws in / blows out — with the speed riding the music exactly the way the classic AVS plugins did it (beatage envelopes drive the rate, [beat] flips the spin on hard hits, persist keeps the fade slow so colors blend). The eleventh Zai-Magic preset "osc-rotations" demonstrates the full recipe: a stationary centered oscilloscope with mirror-symmetric color (left/right shift together, center holds its own hue) over a beat-accelerated, hard-hit-flipping rotating background.