Compare commits

..

6 Commits

Author SHA1 Message Date
64d8b40e61 actual featured page 2026-08-07 18:59:25 -04:00
0cc15c8d90 fix lint: make bun tsc --noEmit actually pass (was a TS crash + latent errors)
tsconfig used jsx:"preserve"+jsxImportSource, which trips a TS 5.9
internal crash ("Expected sourceFile.imports[0] to be the synthesized JSX
runtime import") so tsc could never run clean. Switch to jsx:"react-jsx"
with the package's real jsx-runtime types, and fix the real errors that
surfaced:
- delete dead src/components/Navigation.tsx (imported ./Tab that doesn't
  exist; the component has no importers)
- PlaybackControls: fix relative path to @/utils/audio-player
- SourceBadge: drop dead module-level typeColor (bare 'theme')
- command palette: bind to the real 'command' keybind (:) instead of the
  never-defined 'command_list' action (palette was unreachable)
- yazi-pane-row test: destroy() must return Promise<void> as typed

Also fold in the package.json lint fix (bun tsc --noEmit) and doc polish.
2026-08-07 18:18:25 -04:00
1d3abd53d4 docs: add human-oriented CONTRIBUTING.md (repo map, FFI notes, gotchas, release & tap auto-sync workflow) 2026-08-07 18:09:51 -04:00
592cfd4093 fix search navigation 2026-08-07 17:46:27 -04:00
69e12cf5b9 fix: podtui binary crashed with 'preload not found' when launched from repo root
The embedded runtime reads the CWD bunfig.toml at startup; the repo's
top-level 'preload = ["@opentui/solid/preload"]' made the standalone die
because that module isn't bundled. Removed the ambient preload from
bunfig.toml and moved the solid transform registration to explicit
--preload flags in the start/dev scripts ([test] preload untouched).

Verified: binary boots from repo root, from a clean dir, and dev/start
still work; bun test 54 pass. No new release needed — the binaries were
never config-dependent; this was purely the repo's bunfig trap.
2026-08-07 17:36:11 -04:00
c9e3aa92ec fix release pipeline: vendored cava source, fftw in CI, runner arch, smoke test
- Vendor cava/cavacore.c + header (MIT, from karlstav/cava) — the FFI build
  referenced cava/cavacore.c which was never committed, so every CI runner
  failed at scripts/build-cavacore.sh and no release was possible.
- build-cavacore.sh: discover libfftw3.a across Homebrew and Debian/Ubuntu
  multiarch paths (FFTW_PREFIX override preserved).
- release.yml: install fftw before building cavacore; run the boot smoke test
  from a bunfig-free dir (the embedded runtime reads the CWD bunfig.toml and
  this repo's preload entry breaks it — 'preload not found'); use
  macos-15-intel for darwin-x64 (macos-latest is arm64).
- Makefile/build.ts: drop the no-op BUN_CONFIG=bunfig.standalone.toml compile
  dance (Bun never honored it; compile output is config-independent); delete
  bunfig.standalone.toml.
2026-08-07 14:27:35 -04:00
31 changed files with 3301 additions and 1796 deletions

View File

@@ -29,7 +29,7 @@ jobs:
- os: ubuntu-24.04-arm - os: ubuntu-24.04-arm
arch: arm64 arch: arm64
plat: linux plat: linux
- os: macos-latest - os: macos-15-intel
arch: x64 arch: x64
plat: darwin plat: darwin
- os: macos-14 - os: macos-14
@@ -47,6 +47,15 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Install fftw (cavacore build dependency)
run: |
if uname -s | grep -qi darwin; then
brew install fftw
else
sudo apt-get update
sudo apt-get install -y libfftw3-dev
fi
- name: Build native cavacore library - name: Build native cavacore library
run: scripts/build-cavacore.sh run: scripts/build-cavacore.sh
@@ -57,8 +66,14 @@ jobs:
env: env:
DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz DIST_TAR: podtui-${{ matrix.plat }}-${{ matrix.arch }}.tar.gz
run: | run: |
tar -xzf dist/$DIST_TAR -C dist # The embedded runtime reads the launching process's CWD bunfig.toml.
./dist/podtui --version # This repo's bunfig lists a preload the standalone can't resolve
# ("preload not found"), so kicking the binary from the workspace root
# would falsely fail every build. cd into a clean dir first.
SMOKE_DIR=$(mktemp -d)
tar -xzf "dist/$DIST_TAR" -C "$SMOKE_DIR"
cd "$SMOKE_DIR"
./podtui-*/podtui --version
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

193
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,193 @@
# Contributing to PodTui
This file is written **for humans**. If you're an AI agent or LLM working in
this repo, read [AGENTS.md](AGENTS.md) instead — it has the machine-oriented
build/test/lint contract and code-style rules. Both describe the same project;
CONTRIBUTING.md focuses on *understanding* and *navigating* the codebase.
PodTui is a keyboard-first, yazi-style terminal podcast client. TypeScript +
[OpenTUI](https://github.com/opentui/opentui) on top, [Bun](https://bun.sh)
as the runtime and toolchain.
---
## Quick start
```bash
brew install bun # or: curl -fsSL https://bun.sh/install | bash
git clone git@github.com:mikefreno/podtui.git
cd podtui
bun install # install JS dependencies
make native # build libcavacore.dylib from the vendored C source
bun run dev # launch with hot reload (alias: make dev)
```
The app is a TUI — it expects a real terminal (Ghostty, kitty, iTerm2,
WezTerm, tmux, …). It will not render in a plain captured `bash` session.
## What each command does
| Command | Purpose |
|--------------------|--------------------------------------------------------------------------|
| `bun install` | Install JS dependencies |
| `make native` | Compile `cava/cavacore.c``src/native/libcavacore.<dylib\|so>` |
| `bun run dev` | Run with hot reload |
| `bun run start` | Run once (no watch) |
| `bun test` | Run the test suite (see [Testing](#testing)) |
| `bun run lint` | Type-check |
| `bun run build` | Bundle JS into `dist/` + copy native libs (the `podtui` npm script path) |
| `make dist` | Compile the standalone binary + make the current platform's tarball |
| `make clean` | Remove `dist/` |
## Repository layout
```
src/
api/ Network + XML/RSS — client.ts, rss-parser.ts
components/ Reusable UI pieces: Shell, Navigation, YaziPaneRow, TabPanel…
config/ App config: keybinds.jsonc, shortcuts, auth
constants/ Static tables (sync formats, themes)
context/ Solid contexts: KeybindContext, NavigationContext, ThemeContext
hooks/ useAudio, useMultimediaKeys, useCachedData
native/ FFI glue + the built libcavacore.{dylib,so}
pages/ App screens: Feed, MyShows, Discover, Search, Player, Settings
stores/ Zustand stores — app, feed, audio-nav, search, auth, progress…
styles/ theme.css
themes/ catppuccin, gruvbox, nord, tokyo schemes + schema.json
types/ All shared interfaces (podcast, episode, feed, settings…)
ui/ Modal-adjacent UI: command.tsx, dialog.tsx, toast.tsx
utils/ Parser/persistence/audio helpers (audio-player, config-dir…)
scripts/
build-cavacore.sh C → shared lib; finds libfftw3.a on macOS & Debian
tui-harness.tsx Headless harness for scripted interaction (see below)
cava/ Vendored cavacore C source (MIT, from karlstav/cava)
tests/ bun test suite + cavacore smoke test
dist/ Build output (JS bundle + libs + tarballs)
```
## Native libraries: how the FFI layer works
PodTui loads **two** native libraries at runtime:
1. **libopentui** — the OpenTUI renderer (shipped inside the
`@opentui/core-<platform>-<arch>` npm packages, copied to `dist/` by
`build.ts`).
2. **libcavacore** — the audio spectrum renderer, built from C. The source is
vendored under `cava/` (it must stay committed — every CI runner builds it).
`libfftw3` is needed to build it:
- macOS: `brew install fftw`
- Debian/Ubuntu: `apt-get install libfftw3-dev`
(CI installs it for you; locally run `make native`.)
**Critical sibling rule**: both libraries are loaded *relative to the binary*,
so `podtui`, `libopentui.*` and `libcavacore.*` must sit in the **same
directory**. Never move a single binary out of the tarball. The Homebrew
formula keeps all three in `libexec/` and exposes only a `podtui` symlink.
Cavacore smoke test: `bun tests/cavacore-smoke.ts`
(FFI-calls `cava_init` / `cava_execute` / `cava_destroy` and prints results).
## Gotchas (read before touching anything)
1. **Never add a top-level `preload` to `bunfig.toml`.**
A compiled PodTui binary's embedded runtime reads the *launching process's*
CWD `bunfig.toml`, and a `preload` entry points at a module the standalone
can't resolve (`@opentui/solid/preload`) → the binary dies at startup with
`preload not found`. This is why `bunfig.toml` has **no** top-level
`preload`; dev-mode preloading happens via explicit `--preload` flags in
`package.json`. The `[test]` section *does* keep a preload — that only
affects `bun test`.
2. **Smoke-test the compiled binary from a bunfig-free dir.**
Because of (1), `./dist/podtui --version` run from the repo root launched
inside CI would fail. CI always unpacks the tarball into a `mktemp` dir
before booting. Do the same when testing a release build locally.
3. **Homebrew's dylib-repair warning is benign.**
`brew install` may print “load commands do not fit in the header … needs
`-headerpad`” for a prebuilt dylib. The app dlopens the libs by path, so
the warning is cosmetic; installs complete and the app boots.
## Testing
```bash
bun test # full suite (54 tests across 6 files today)
```
The suite covers the keyboard/nav model, keybind dispatch, and the yazi pane
logic; plus `tests/cavacore-smoke.ts` asserting the native lib exports.
For scripted end-to-end interaction there's a **headless harness**,
`scripts/tui-harness.tsx`: each invocation snapshot-rebuilds the app state
into a sandboxed `.harness/` config dir, replays the saved action log
(`.harness/actions.json`), executes one more key/action passed on the CLI, and
prints the resulting frame + a style summary — all without a real terminal.
Audio is a no-op during those snapshots. The last frame lands in
`.harness/last-frame.{json,txt}` for inspection.
## Releasing
Releases are built and published from **tags**
### Steps
1. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0``0.2.0`). Commit and push.
2. Tag and push:
```bash
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
```
3. CI (`.github/workflows/release.yml`) runs four builds in parallel,
each producing `podtui-<platform>-<arch>.tar.gz`:
| Runner | Platform/Arch |
|---------------------|---------------|
| `ubuntu-latest` | linux-x64 |
| `ubuntu-24.04-arm` | linux-arm64 |
| `macos-15-intel` | darwin-x64 |
| `macos-14` | darwin-arm64 |
Each runner: installs deps → installs fftw → `scripts/build-cavacore.sh`
→ `make dist` → smoke-boots the binary from a temp dir → uploads the
tarball. (`macos-15-intel` matters: GitHub's `macos-latest` is arm64 now.)
4. A release is auto-created with all 4 tarballs attached. `brew` never
sees the new version: the **tap self-updates**: the
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
polls GitHub releases, and when a new tag appears, rewrites
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
test: `brew install mikefreno/podtui/podtui`.
### Manual fallback
If you ever need to sync the tap by hand (or before the hourly job runs):
```bash
cd <clone of mikefreno/homebrew-podtui>
./scripts/sync-formula.sh 0.2.0
git commit -am 'podtui 0.2.0' && git push
```
### Local release build
```bash
make dist # builds the binary + tarball for THIS machine only
```
Bun cannot cross-compile — the other platforms come from CI.
---
## Open items / things to sort out
- **LICENSE**: `README.md` says "TBD — choose and document a license before
the first release". Pick one (MIT/BSD-3) and add `LICENSE` + update the
README footer.
- **Native libs in `dist/` still need committing?** No — they're built from
sources kept in the repo (`cava/`, `node_modules/@opentui/core-*`). Only
`src/native/libcavacore.dylib` is a committed binary artifact; macOS arm64
ships from it directly until a full rebuild replaces it. On other hosts the
`make native` build is required — see `scripts/build-cavacore.sh`.

View File

@@ -47,18 +47,19 @@ native:
scripts/build-cavacore.sh scripts/build-cavacore.sh
## Standalone binary + native-libs tarball for the current platform. ## Standalone binary + native-libs tarball for the current platform.
## Compiles against an empty bunfig so the binary does not bake the ## Unaffected by bunfig.toml at build time. Note: the compiled runtime reads
## @opentui/solid/preload entry (which would break the compiled executable). ## the launching process's CWD bunfig.toml, so smoke tests must run the binary
## from a bunfig-free dir (see release.yml).
dist: dist:
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile bun run build.ts --compile
## macOS build (run on a macOS runner / host). ## macOS build (run on a macOS runner / host).
dist-mac: dist-mac:
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile bun run build.ts --compile
## Linux build (run on a Linux runner / host). ## Linux build (run on a Linux runner / host).
dist-linux: dist-linux:
BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile bun run build.ts --compile
## Remove build artifacts. ## Remove build artifacts.
clean: clean:

View File

@@ -70,6 +70,11 @@ sudo install -m755 podtui /usr/local/bin/podtui
> The tarball contains `podtui` plus `libopentui.<ext>` and > The tarball contains `podtui` plus `libopentui.<ext>` and
> `libcavacore.<ext>` **beside it** — keep them together (don't move just the > `libcavacore.<ext>` **beside it** — keep them together (don't move just the
> binary alone), or the native FFI libraries won't load. > binary alone), or the native FFI libraries won't load.
>
> One caveat: the embedded runtime reads a `bunfig.toml` from the directory
> you launch from. If that file has a `preload` entry (as Bun project
> directories often do), startup fails with `preload not found`. Launching
> from a normal directory (home, `~/bin`, …) works fine.
### 3. Arch Linux (AUR) ### 3. Arch Linux (AUR)
@@ -187,9 +192,10 @@ make dist-mac # (run on macOS) → podtui-darwin-<arch>.tar.gz
make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz make dist-linux # (run on Linux) → podtui-linux-<arch>.tar.gz
``` ```
`make dist` compiles against `bunfig.standalone.toml` (a preload-free Bun `make dist` emits a config-independent binary: Bun does not bake bunfig
config) so the emitted binary doesn't bake in the dev-only `@opentui/solid` settings into `--compile` output, and the solid JSX transform is registered in
preload. The solid JSX transform is registered in `build.ts` itself. `build.ts` itself. The binary then embeds the `preload`-free runtime, so launch
it from any normal directory.
## Packaging model ## Packaging model

View File

@@ -5,9 +5,8 @@ import { plugin } from "bun";
// Register the solid transform globally (dedup'd by name). This is what makes // Register the solid transform globally (dedup'd by name). This is what makes
// `--compile` work: compile-mode builds only apply `onLoad` transform plugins // `--compile` work: compile-mode builds only apply `onLoad` transform plugins
// that are registered via `plugin()`, not the `plugins:` array. The compiled // that are registered via `plugin()`, not the `plugins:` array. The transform
// binary is then built against an empty bunfig (PODTUI_COMPILE config) so the // is fully embedded in the compiled binary.
// runtime bakes NO preload — the solid transform is already in the binary.
plugin(solidPlugin); plugin(solidPlugin);
const COMPILE = const COMPILE =

View File

@@ -1,11 +0,0 @@
# Standalone compile config for `bun build --compile` / `make dist`.
#
# This file MUST stay free of a `preload` key: Bun bakes bunfig preloads into
# compiled binaries as launch metadata, and `@opentui/solid/preload` (used for
# `bun run` dev/test) isn't embedded in the standalone, so a baked-in preload
# makes the compiled binary fail at startup with:
# error: preload not found "@opentui/solid/preload"
#
# The solid JSX transform is registered in build.ts itself (`plugin(solidPlugin)`),
# so compiling against this config needs no global preload. Invoke as:
# BUN_CONFIG=bunfig.standalone.toml bun run build.ts --compile

View File

@@ -1,4 +1,9 @@
preload = ["@opentui/solid/preload"] # NO top-level `preload` here — intentional. A compiled PodTUI binary's
# embedded Bun runtime reads the launching process's CWD bunfig.toml, and a
# top-level `preload` entry (e.g. "@opentui/solid/preload", which the
# standalone cannot resolve) makes the binary die at startup with
# "preload not found". Dev/test still get the solid transform via explicit
# `--preload` flags in package.json and the [test] section below.
[test] [test]
preload = "@opentui/solid/preload" preload = "@opentui/solid/preload"

19
cava/LICENSE-cava.txt Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2015 Karl Stavestrand <karl@stavestrand.no>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

588
cava/cavacore.c Normal file
View File

@@ -0,0 +1,588 @@
#include "cavacore.h"
#ifndef M_PI
#define M_PI 3.1415926535897932385
#endif
#include <fftw3.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>
#ifdef __ANDROID__
#include <jni.h>
struct cava_plan *plan;
double *cava_in;
double *cava_out;
#endif
static double amplitude_to_decibels(double value) {
// Magic number 20 comes from converting amplitude ratios to decibels.
return 20 * log10(value);
}
struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels, int autosens,
double noise_reduction, int low_cut_off, int high_cut_off,
int scaling_mode) {
struct cava_plan *p = malloc(sizeof(struct cava_plan));
p->status = 0;
// sanity checks:
if (channels < 1 || channels > 2) {
snprintf(p->error_message, 1024,
"cava_init called with illegal number of channels: %d, number of channels "
"supported are "
"1 and 2",
channels);
p->status = -1;
return p;
}
if (rate < 1 || rate > 384000) {
snprintf(p->error_message, 1024, "cava_init called with illegal sample rate: %d\n", rate);
p->status = -1;
return p;
}
int fft_buffer_size = 512;
if (rate > 8125 && rate <= 16250)
fft_buffer_size *= 2;
else if (rate > 16250 && rate <= 32500)
fft_buffer_size *= 4;
else if (rate > 32500 && rate <= 75000)
fft_buffer_size *= 8;
else if (rate > 75000 && rate <= 150000)
fft_buffer_size *= 16;
else if (rate > 150000 && rate <= 300000)
fft_buffer_size *= 32;
else if (rate > 300000)
fft_buffer_size *= 64;
if (number_of_bars < 1) {
snprintf(p->error_message, 1024,
"cava_init called with illegal number of bars: %d, number of channels must be "
"positive integer\n",
number_of_bars);
p->status = -1;
return p;
}
if (number_of_bars > fft_buffer_size / 2 + 1) {
snprintf(p->error_message, 1024,
"cava_init called with illegal number of bars: %d, for %d sample rate number of "
"bars can't be more than %d\n",
number_of_bars, rate, fft_buffer_size / 2 + 1);
p->status = -1;
return p;
}
if (low_cut_off < 1 || high_cut_off < 1) {
snprintf(p->error_message, 1024, "low_cut_off must be a positive value\n");
p->status = -1;
return p;
}
if (low_cut_off >= high_cut_off) {
snprintf(p->error_message, 1024, "high_cut_off must be a higher than low_cut_off\n");
p->status = -1;
return p;
}
if ((unsigned int)high_cut_off > rate / 2) {
snprintf(p->error_message, 1024,
"high_cut_off can't be higher than sample rate / 2. (Nyquist Sampling Theorem)\n");
p->status = -1;
return p;
}
if (scaling_mode != CAVA_SCALING_LINEAR && scaling_mode != CAVA_SCALING_DECIBEL) {
snprintf(p->error_message, 1024, "unknown scaling mode: %d\n", scaling_mode);
p->status = -1;
return p;
}
p->number_of_bars = number_of_bars;
p->audio_channels = channels;
p->rate = rate;
p->autosens = 1;
p->sens_init = 1;
p->sens = 1.0;
p->autosens = autosens;
p->framerate = 75;
p->frame_skip = 1;
p->noise_reduction = noise_reduction;
p->scaling_mode = scaling_mode;
int fftw_flag = FFTW_MEASURE;
#ifdef __ANDROID__
fftw_flag = FFTW_ESTIMATE;
#endif
p->FFTbassbufferSize = fft_buffer_size * 2;
p->FFTbufferSize = fft_buffer_size;
p->input_buffer_size = p->FFTbassbufferSize * channels;
p->input_buffer = (double *)malloc(p->input_buffer_size * sizeof(double));
p->FFTbuffer_lower_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
p->FFTbuffer_upper_cut_off = (int *)malloc((number_of_bars + 1) * sizeof(int));
p->eq = (double *)malloc((number_of_bars + 1) * sizeof(double));
p->cut_off_frequency = (float *)malloc((number_of_bars + 1) * sizeof(float));
p->cava_fall = (double *)malloc(number_of_bars * channels * sizeof(double));
p->cava_mem = (double *)malloc(number_of_bars * channels * sizeof(double));
p->cava_peak = (double *)malloc(number_of_bars * channels * sizeof(double));
p->prev_cava_out = (double *)malloc(number_of_bars * channels * sizeof(double));
// Hann Window calculate multipliers
p->bass_multiplier = (double *)malloc(p->FFTbassbufferSize * sizeof(double));
p->multiplier = (double *)malloc(p->FFTbufferSize * sizeof(double));
for (int i = 0; i < p->FFTbassbufferSize; i++) {
p->bass_multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbassbufferSize - 1)));
}
for (int i = 0; i < p->FFTbufferSize; i++) {
p->multiplier[i] = 0.5 * (1 - cos(2 * M_PI * i / (p->FFTbufferSize - 1)));
}
// BASS
p->in_bass_l = fftw_alloc_real(p->FFTbassbufferSize);
p->in_bass_l_raw = fftw_alloc_real(p->FFTbassbufferSize);
p->out_bass_l = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
p->p_bass_l =
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_l, p->out_bass_l, fftw_flag);
// MID + TREBLE
p->in_l = fftw_alloc_real(p->FFTbufferSize);
p->in_l_raw = fftw_alloc_real(p->FFTbufferSize);
p->out_l = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
p->p_l = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_l, p->out_l, fftw_flag);
memset(p->in_bass_l, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_l, 0, sizeof(double) * p->FFTbufferSize);
memset(p->in_bass_l_raw, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_l_raw, 0, sizeof(double) * p->FFTbufferSize);
memset(p->out_bass_l, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
memset(p->out_l, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
if (p->audio_channels == 2) {
// BASS
p->in_bass_r = fftw_alloc_real(p->FFTbassbufferSize);
p->in_bass_r_raw = fftw_alloc_real(p->FFTbassbufferSize);
p->out_bass_r = fftw_alloc_complex(p->FFTbassbufferSize / 2 + 1);
p->p_bass_r =
fftw_plan_dft_r2c_1d(p->FFTbassbufferSize, p->in_bass_r, p->out_bass_r, fftw_flag);
// MID + TREBLE
p->in_r = fftw_alloc_real(p->FFTbufferSize);
p->in_r_raw = fftw_alloc_real(p->FFTbufferSize);
p->out_r = fftw_alloc_complex(p->FFTbufferSize / 2 + 1);
p->p_r = fftw_plan_dft_r2c_1d(p->FFTbufferSize, p->in_r, p->out_r, fftw_flag);
memset(p->in_bass_r, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_r, 0, sizeof(double) * p->FFTbufferSize);
memset(p->in_bass_r_raw, 0, sizeof(double) * p->FFTbassbufferSize);
memset(p->in_r_raw, 0, sizeof(double) * p->FFTbufferSize);
memset(p->out_bass_r, 0, (p->FFTbassbufferSize / 2 + 1) * sizeof(fftw_complex));
memset(p->out_r, 0, (p->FFTbufferSize / 2 + 1) * sizeof(fftw_complex));
}
memset(p->input_buffer, 0, sizeof(double) * p->input_buffer_size);
memset(p->cava_fall, 0, sizeof(double) * number_of_bars * channels);
memset(p->cava_mem, 0, sizeof(double) * number_of_bars * channels);
memset(p->cava_peak, 0, sizeof(double) * number_of_bars * channels);
memset(p->prev_cava_out, 0, sizeof(double) * number_of_bars * channels);
// process: calculate cutoff frequencies and eq
int lower_cut_off = low_cut_off;
int upper_cut_off = high_cut_off;
int bass_cut_off = 100;
// calculate frequency constant (used to distribute bars across the frequency band)
double frequency_constant = log10((float)lower_cut_off / (float)upper_cut_off) /
(1 / ((float)p->number_of_bars + 1) - 1);
float *relative_cut_off = (float *)malloc((p->number_of_bars + 1) * sizeof(float));
p->bass_cut_off_bar = 0;
int first_bar = 1;
float min_bandwidth = p->rate / p->FFTbassbufferSize;
for (int n = 0; n < p->number_of_bars + 1; n++) {
double bar_distribution_coefficient = frequency_constant * (-1);
bar_distribution_coefficient +=
((float)n + 1) / ((float)p->number_of_bars + 1) * frequency_constant;
p->cut_off_frequency[n] = upper_cut_off * pow(10, bar_distribution_coefficient);
if (n > 0) {
if (p->cut_off_frequency[n - 1] >= p->cut_off_frequency[n])
p->cut_off_frequency[n] = p->cut_off_frequency[n - 1] + min_bandwidth;
}
// remember nyquist!
relative_cut_off[n] = p->cut_off_frequency[n] / (p->rate / 2);
if (p->cut_off_frequency[n] < bass_cut_off) {
// BASS
p->FFTbuffer_lower_cut_off[n] = relative_cut_off[n] * (p->FFTbassbufferSize / 2);
p->bass_cut_off_bar++;
if (p->bass_cut_off_bar > 1)
first_bar = 0;
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbassbufferSize / 2) {
p->FFTbuffer_lower_cut_off[n] = p->FFTbassbufferSize / 2;
}
} else {
// MID + TREBLE
p->FFTbuffer_lower_cut_off[n] =
ceil(relative_cut_off[n] * (float)(p->FFTbufferSize / 2));
if (n == p->bass_cut_off_bar) {
first_bar = 1;
if (n > 0) {
p->FFTbuffer_upper_cut_off[n - 1] =
relative_cut_off[n] * (p->FFTbassbufferSize / 2) - 1;
}
} else {
first_bar = 0;
}
if (p->FFTbuffer_lower_cut_off[n] > p->FFTbufferSize / 2) {
p->FFTbuffer_lower_cut_off[n] = p->FFTbufferSize / 2;
}
}
if (n > 0) {
if (!first_bar) {
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
// pushing the spectrum up if the exponential function gets "clumped" in the
// bass and calculating new cut off frequencies
if (p->FFTbuffer_lower_cut_off[n] <= p->FFTbuffer_lower_cut_off[n - 1]) {
// check if there is room for more first
int room_for_more = 0;
if (n < p->bass_cut_off_bar) {
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbassbufferSize / 2 + 1)
room_for_more = 1;
} else {
if (p->FFTbuffer_lower_cut_off[n - 1] + 1 < p->FFTbufferSize / 2 + 1)
room_for_more = 1;
}
if (room_for_more) {
// push the spectrum up
p->FFTbuffer_lower_cut_off[n] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n] - 1;
}
}
} else {
if (p->FFTbuffer_upper_cut_off[n - 1] < p->FFTbuffer_lower_cut_off[n - 1])
p->FFTbuffer_upper_cut_off[n - 1] = p->FFTbuffer_lower_cut_off[n - 1] + 1;
}
}
// calculate actual cut off frequency
if (n < p->bass_cut_off_bar)
relative_cut_off[n] =
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbassbufferSize / 2);
else
relative_cut_off[n] =
(float)(p->FFTbuffer_lower_cut_off[n]) / ((float)p->FFTbufferSize / 2);
p->cut_off_frequency[n] = relative_cut_off[n] * ((float)p->rate / 2);
}
// hard coded eq
for (int n = 0; n < p->number_of_bars; n++) {
// the numbers that come out of the FFT are very high
// the EQ is used to "normalize" them by dividing with this very huge number
p->eq[n] = 1 / pow(2, 28);
// need to boost the EQ for higher frequencies
p->eq[n] *= pow(p->cut_off_frequency[n + 1], 0.85);
if (n < p->bass_cut_off_bar) {
p->eq[n] /= log2(p->FFTbassbufferSize);
} else {
p->eq[n] /= log2(p->FFTbufferSize);
}
p->eq[n] /= p->FFTbuffer_upper_cut_off[n] - p->FFTbuffer_lower_cut_off[n] + 1;
}
free(relative_cut_off);
return p;
}
void cava_execute(double *cava_in, int new_samples, double *cava_out, struct cava_plan *p) {
// do not overflow
if (new_samples > p->input_buffer_size) {
new_samples = p->input_buffer_size;
}
int silence = 1;
if (new_samples > 0) {
// process: approximate actual framerate. This will be off by +10% at 60 fps, but should be
// good enough for the autosens and smoothing algorithms to be adjusted accordingly if
// framerate is a lot more or less.
p->framerate -= p->framerate / 64.0;
p->framerate +=
(double)(p->rate * p->frame_skip) / (new_samples / p->audio_channels) / 64.0;
p->frame_skip = 1;
// shifting input buffer
for (int n = p->input_buffer_size - 1; n >= new_samples; n--) {
p->input_buffer[n] = p->input_buffer[n - new_samples];
}
// fill the input buffer
for (int n = 0; n < new_samples; n++) {
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
// Audio signals come in the range [-32768, 32768], normalize to [-1, 1].
p->input_buffer[new_samples - n - 1] = cava_in[n] / 32768.0;
} else {
p->input_buffer[new_samples - n - 1] = cava_in[n];
}
if (cava_in[n]) {
silence = 0;
}
}
} else {
p->frame_skip++;
}
// fill the bass, mid and treble buffers
for (int n = 0; n < p->FFTbassbufferSize; n++) {
if (p->audio_channels == 2) {
p->in_bass_r_raw[n] = p->input_buffer[n * 2];
p->in_bass_l_raw[n] = p->input_buffer[n * 2 + 1];
} else {
p->in_bass_l_raw[n] = p->input_buffer[n];
}
}
for (int n = 0; n < p->FFTbufferSize; n++) {
if (p->audio_channels == 2) {
p->in_r_raw[n] = p->input_buffer[n * 2];
p->in_l_raw[n] = p->input_buffer[n * 2 + 1];
} else {
p->in_l_raw[n] = p->input_buffer[n];
}
}
// Hann Window
for (int i = 0; i < p->FFTbassbufferSize; i++) {
p->in_bass_l[i] = p->bass_multiplier[i] * p->in_bass_l_raw[i];
if (p->audio_channels == 2)
p->in_bass_r[i] = p->bass_multiplier[i] * p->in_bass_r_raw[i];
}
for (int i = 0; i < p->FFTbufferSize; i++) {
p->in_l[i] = p->multiplier[i] * p->in_l_raw[i];
if (p->audio_channels == 2)
p->in_r[i] = p->multiplier[i] * p->in_r_raw[i];
}
// process: execute FFT and sort frequency bands
fftw_execute(p->p_bass_l);
fftw_execute(p->p_l);
if (p->audio_channels == 2) {
fftw_execute(p->p_bass_r);
fftw_execute(p->p_r);
}
// process: separate frequency bands
for (int n = 0; n < p->number_of_bars; n++) {
double temp_l = 0;
double temp_r = 0;
// process: add upp FFT values within bands
for (int i = p->FFTbuffer_lower_cut_off[n]; i <= p->FFTbuffer_upper_cut_off[n]; i++) {
if (n < p->bass_cut_off_bar) {
temp_l += hypot(p->out_bass_l[i][0], p->out_bass_l[i][1]);
if (p->audio_channels == 2)
temp_r += hypot(p->out_bass_r[i][0], p->out_bass_r[i][1]);
} else {
temp_l += hypot(p->out_l[i][0], p->out_l[i][1]);
if (p->audio_channels == 2)
temp_r += hypot(p->out_r[i][0], p->out_r[i][1]);
}
}
// getting average and applying configured scaling
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
const double max_db = 70;
temp_l = amplitude_to_decibels(temp_l) / max_db;
if (!isfinite(temp_l)) {
temp_l = 0;
}
} else {
temp_l *= p->eq[n];
}
cava_out[n] = temp_l;
if (p->audio_channels == 2) {
if (p->scaling_mode == CAVA_SCALING_DECIBEL) {
const double max_db = 70;
temp_r = amplitude_to_decibels(temp_r) / max_db;
if (!isfinite(temp_r)) {
temp_r = 0;
}
} else {
temp_r *= p->eq[n];
}
cava_out[n + p->number_of_bars] = temp_r;
}
}
// applying sens or getting max value
if (p->autosens) {
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
cava_out[n] *= p->sens;
}
}
// process [smoothing]
int overshoot = 0;
double framerate_mod = 66 / p->framerate;
double gravity_mod = pow((framerate_mod), 2.5) * 2 / p->noise_reduction;
double integral_mod = pow((framerate_mod), 0.1);
for (int n = 0; n < p->number_of_bars * p->audio_channels; n++) {
// process [smoothing]: falloff
if (cava_out[n] < p->prev_cava_out[n] && p->noise_reduction > 0.1) {
cava_out[n] =
p->cava_peak[n] * (1.0 - (p->cava_fall[n] * p->cava_fall[n] * gravity_mod));
if (cava_out[n] < 0.0)
cava_out[n] = 0.0;
p->cava_fall[n] += 0.028;
} else {
p->cava_peak[n] = cava_out[n];
p->cava_fall[n] = 0.0;
}
p->prev_cava_out[n] = cava_out[n];
// process [smoothing]: integral
cava_out[n] = p->cava_mem[n] * p->noise_reduction / integral_mod + cava_out[n];
p->cava_mem[n] = cava_out[n];
if (p->autosens) {
// check if we overshoot target height
if (cava_out[n] > 1.0) {
overshoot = 1;
cava_out[n] = 1.0;
}
}
}
// calculating automatic sense adjustment
if (p->autosens) {
if (overshoot) {
p->sens = p->sens * (1 - (0.02 * framerate_mod));
p->sens_init = 0;
} else {
if (!silence) {
p->sens = p->sens * (1 + (0.001 * framerate_mod * p->autosens));
if (p->sens_init)
p->sens = p->sens * (1 + (0.1 * framerate_mod));
}
}
}
}
void cava_destroy(struct cava_plan *p) {
free(p->input_buffer);
free(p->bass_multiplier);
free(p->multiplier);
free(p->eq);
free(p->cut_off_frequency);
free(p->FFTbuffer_lower_cut_off);
free(p->FFTbuffer_upper_cut_off);
free(p->cava_fall);
free(p->cava_mem);
free(p->cava_peak);
free(p->prev_cava_out);
fftw_free(p->in_bass_l);
fftw_free(p->in_bass_l_raw);
fftw_free(p->out_bass_l);
fftw_destroy_plan(p->p_bass_l);
fftw_free(p->in_l);
fftw_free(p->in_l_raw);
fftw_free(p->out_l);
fftw_destroy_plan(p->p_l);
if (p->audio_channels == 2) {
fftw_free(p->in_bass_r);
fftw_free(p->in_bass_r_raw);
fftw_free(p->out_bass_r);
fftw_destroy_plan(p->p_bass_r);
fftw_free(p->in_r);
fftw_free(p->out_r);
fftw_free(p->in_r_raw);
fftw_destroy_plan(p->p_r);
}
}
#ifdef __ANDROID__
JNIEXPORT jfloatArray JNICALL Java_com_karlstav_cava_MyGLRenderer_InitCava(
JNIEnv *env, jobject thiz, jint number_of_bars_set, jint refresh_rate, jint lower_cut_off,
jint higher_cut_off) {
jfloatArray cuttOffFreq = (*env)->NewFloatArray(env, number_of_bars_set + 1);
float noise_reduction = pow((float)refresh_rate / 130, 0.75);
plan = cava_init(number_of_bars_set, 44100, 1, 1, noise_reduction, lower_cut_off,
higher_cut_off, CAVA_SCALING_LINEAR);
cava_in = (double *)malloc(plan->FFTbassbufferSize * sizeof(double));
cava_out = (double *)malloc(plan->number_of_bars * sizeof(double));
(*env)->SetFloatArrayRegion(env, cuttOffFreq, 0, plan->number_of_bars + 1,
plan->cut_off_frequency);
return cuttOffFreq;
}
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_MyGLRenderer_ExecCava(JNIEnv *env,
jobject thiz,
jdoubleArray cava_input,
jint new_samples) {
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
cava_execute(cava_in, new_samples, cava_out, plan);
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
return cavaReturn;
}
JNIEXPORT int JNICALL Java_com_karlstav_cava_CavaCoreTest_InitCava(JNIEnv *env, jobject thiz,
jint number_of_bars_set) {
plan = cava_init(number_of_bars_set, 44100, 1, 1, 0.7, 50, 10000, CAVA_SCALING_LINEAR);
return 1;
}
JNIEXPORT jdoubleArray JNICALL Java_com_karlstav_cava_CavaCoreTest_ExecCava(JNIEnv *env,
jobject thiz,
jdoubleArray cava_input,
jint new_samples) {
jdoubleArray cavaReturn = (*env)->NewDoubleArray(env, plan->number_of_bars);
cava_in = (*env)->GetDoubleArrayElements(env, cava_input, NULL);
cava_execute(cava_in, new_samples, cava_out, plan);
(*env)->SetDoubleArrayRegion(env, cavaReturn, 0, plan->number_of_bars, cava_out);
(*env)->ReleaseDoubleArrayElements(env, cava_input, cava_in, JNI_ABORT);
return cavaReturn;
}
JNIEXPORT void JNICALL Java_com_karlstav_cava_MyGLRenderer_DestroyCava(JNIEnv *env, jobject thiz) {
cava_destroy(plan);
}
#endif

139
cava/cavacore.h Normal file
View File

@@ -0,0 +1,139 @@
/*
Copyright (c) 2022 Karl Stavestrand <karl@stavestrand.no>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
#ifdef __cplusplus
extern "C" {
#endif
#pragma once
#include <stdint.h>
#include <fftw3.h>
#define CAVA_SCALING_LINEAR 0
#define CAVA_SCALING_DECIBEL 1
// cava_plan, parameters used internally by cavacore, do not modify these directly
// only the cut off frequencies is of any potential interest to read out,
// the rest should most likely be hidden somehow
struct cava_plan {
int FFTbassbufferSize;
int FFTbufferSize;
int number_of_bars;
int audio_channels;
int input_buffer_size;
int rate;
int bass_cut_off_bar;
int sens_init;
int autosens;
int frame_skip;
int status;
int scaling_mode;
char error_message[1024];
double sens;
double framerate;
double noise_reduction;
fftw_plan p_bass_l, p_bass_r;
fftw_plan p_l, p_r;
fftw_complex *out_bass_l, *out_bass_r;
fftw_complex *out_l, *out_r;
double *bass_multiplier;
double *multiplier;
double *in_bass_r_raw, *in_bass_l_raw;
double *in_r_raw, *in_l_raw;
double *in_bass_r, *in_bass_l;
double *in_r, *in_l;
double *prev_cava_out, *cava_mem;
double *input_buffer, *cava_peak;
double *eq;
float *cut_off_frequency;
int *FFTbuffer_lower_cut_off;
int *FFTbuffer_upper_cut_off;
double *cava_fall;
};
// cava_init, initialize visualization, takes the following parameters:
// number_of_bars, number of wanted bars per channel
// rate, sample rate of input signal
// channels, number of interleaved channels in input
// autosens, toggle automatic sensitivity adjustment 1 = on, 0 = off
// on, gives a dynamically adjusted output signal from 0 to 1
// the output is continuously adjusted to use the entire range
// off, will pass the raw values from cava directly to the output
// the max values will then be dependent on the input
// noise_reduction, adjust noise reduction filters. 0 - 1, recommended 0.77
// the raw visualization is very noisy, this factor adjusts the integral
// and gravity filters inside cavacore to keep the signal smooth
// 1 will be very slow and smooth, 0 will be fast but noisy.
// low_cut_off, high_cut_off cut off frequencies for visualization in Hz
// recommended: 50, 10000
// scaling_mode, output scaling mode:
// CAVA_SCALING_LINEAR = legacy linear scaling
// CAVA_SCALING_DECIBEL = dB-based logarithmic scaling
// returns a cava_plan to be used by cava_execute. If cava_plan.status is 0 all is OK.
// If cava_plan.status is -1, cava_init was called with an illegal parameter, see error string in
// cava_plan.error_message
extern struct cava_plan *cava_init(int number_of_bars, unsigned int rate, int channels,
int autosens, double noise_reduction, int low_cut_off,
int high_cut_off, int scaling_mode);
// cava_execute, executes visualization
// cava_in, input buffer can be any size. internal buffers in cavacore is
// 4096 * number of channels at 44100 samples rate, if new_samples is greater
// then samples will be discarded. However it is recommended to use less
// new samples per execution as this determines your framerate.
// 512 samples at 44100 sample rate mono, gives about 86 frames per second.
// new_samples, the number of samples in cava_in to be processed per execution
// in case of async reading of data this number is allowed to vary from execution to execution
// cava_out, output buffer. Size must be number of bars * number of channels. Bars will
// be sorted from lowest to highest frequency. If stereo input channels are configured
// then all left channel bars will be first then the right.
// plan, the cava_plan struct returned from cava_init
// cava_execute assumes cava_in samples to be interleaved if more than one channel
// only up to two channels are supported.
extern void cava_execute(double *cava_in, int new_samples, double *cava_out,
struct cava_plan *plan);
// cava_destroy, destroys the plan, frees up memory
extern void cava_destroy(struct cava_plan *plan);
#ifdef __cplusplus
}
#endif

405
discover/featured.json Normal file
View File

@@ -0,0 +1,405 @@
{
"version": 1,
"podcasts": [
{
"id": "discover-daily",
"title": "The Daily",
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
"feedUrl": "http://rss.art19.com/the-daily",
"author": "The New York Times",
"categories": ["News", "Politics"]
},
{
"id": "discover-up-first",
"title": "Up First",
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
"author": "NPR",
"categories": ["News"]
},
{
"id": "discover-npr-politics",
"title": "The NPR Politics Podcast",
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
"author": "NPR",
"categories": ["News", "Politics"]
},
{
"id": "discover-code-switch",
"title": "Code Switch",
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
"feedUrl": "https://feeds.npr.org/510352/podcast.xml",
"author": "NPR",
"categories": ["News", "Culture", "Politics"]
},
{
"id": "discover-rough-translation",
"title": "Rough Translation",
"description": "How are the things we're talking about covered in the rest of the world? NPR's Rough Translation takes you to far-off places and shows you the unexpected.",
"feedUrl": "https://feeds.npr.org/510324/podcast.xml",
"author": "NPR",
"categories": ["News", "Culture"]
},
{
"id": "discover-crime-junkie",
"title": "Crime Junkie",
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
"author": "audiochuck",
"categories": ["True Crime"]
},
{
"id": "discover-serial",
"title": "Serial",
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
"author": "Serial Productions & The New York Times",
"categories": ["True Crime", "Storytelling"]
},
{
"id": "discover-intelligence-matters",
"title": "Intelligence Matters",
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
"feedUrl": "https://rss.art19.com/intelligence-matters",
"author": "CBS News",
"categories": ["True Crime", "Politics", "News"]
},
{
"id": "discover-smartless",
"title": "SmartLess",
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
"feedUrl": "https://rss.art19.com/smartless",
"author": "Jason Bateman, Sean Hayes, Will Arnett",
"categories": ["Comedy", "Entertainment"]
},
{
"id": "discover-this-past-weekend",
"title": "This Past Weekend w/ Theo Von",
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
"author": "Theo Von",
"categories": ["Comedy"]
},
{
"id": "discover-joe-rogan",
"title": "The Joe Rogan Experience",
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
"author": "Joe Rogan",
"categories": ["Comedy", "Entertainment"]
},
{
"id": "discover-comedy-bang-bang",
"title": "Comedy Bang Bang: The Podcast",
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
"author": "Earwolf",
"categories": ["Comedy"]
},
{
"id": "discover-office-ladies",
"title": "Office Ladies",
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
"feedUrl": "https://rss.art19.com/office-ladies",
"author": "Earwolf",
"categories": ["Comedy", "Entertainment"]
},
{
"id": "discover-how-did-this-get-made",
"title": "How Did This Get Made?",
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
"author": "Earwolf",
"categories": ["Comedy", "Film"]
},
{
"id": "discover-wait-wait",
"title": "Wait Wait... Don't Tell Me!",
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
"author": "NPR",
"categories": ["Comedy", "News"]
},
{
"id": "discover-new-heights",
"title": "New Heights with Jason & Travis Kelce",
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
"feedUrl": "https://rss.art19.com/new-heights",
"author": "Jason & Travis Kelce",
"categories": ["Sports", "Comedy"]
},
{
"id": "discover-bill-simmons",
"title": "The Bill Simmons Podcast",
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
"author": "The Ringer",
"categories": ["Sports", "Entertainment"]
},
{
"id": "discover-acquired",
"title": "Acquired",
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
"feedUrl": "https://feeds.transistor.fm/acquired",
"author": "Ben Gilbert & David Rosenthal",
"categories": ["Business", "Technology"]
},
{
"id": "discover-all-in",
"title": "All-In Podcast",
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
"feedUrl": "https://feeds.transistor.fm/all-in",
"author": "Chamath, Jason, Sacks & Friedberg",
"categories": ["Business", "Technology", "Politics"]
},
{
"id": "discover-planet-money",
"title": "Planet Money",
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
"author": "NPR",
"categories": ["Business", "Economics"]
},
{
"id": "discover-how-i-built-this",
"title": "How I Built This with Guy Raz",
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
"author": "NPR / Wondery",
"categories": ["Business", "Technology"]
},
{
"id": "discover-freakonomics",
"title": "Freakonomics Radio",
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
"author": "Stephen J. Dubner",
"categories": ["Business", "Economics", "Society"]
},
{
"id": "discover-darknet-diaries",
"title": "Darknet Diaries",
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
"feedUrl": "https://podcast.darknetdiaries.com/",
"author": "Jack Rhysider",
"categories": ["Technology", "True Crime"]
},
{
"id": "discover-changelog",
"title": "The Changelog",
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
"feedUrl": "https://changelog.fm/rss",
"author": "Changelog Media",
"categories": ["Technology", "Software Engineering"]
},
{
"id": "discover-twit",
"title": "This Week in Tech (TWiT)",
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
"feedUrl": "https://feeds.twit.tv/twit.xml",
"author": "TWiT",
"categories": ["Technology"]
},
{
"id": "discover-radiolab",
"title": "Radiolab",
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
"feedUrl": "http://feeds.wnyc.org/radiolab",
"author": "WNYC Studios",
"categories": ["Science", "Storytelling"]
},
{
"id": "discover-huberman-lab",
"title": "Huberman Lab",
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
"author": "Scicomm Media",
"categories": ["Health", "Science"]
},
{
"id": "discover-skeptics-guide",
"title": "The Skeptics' Guide to the Universe",
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
"author": "Steven Novella",
"categories": ["Science", "Philosophy"]
},
{
"id": "discover-throughline",
"title": "Throughline",
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
"author": "NPR",
"categories": ["History", "Politics"]
},
{
"id": "discover-hardcore-history",
"title": "Dan Carlin's Hardcore History",
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
"author": "Dan Carlin",
"categories": ["History"]
},
{
"id": "discover-history-of-rome",
"title": "The History of Rome",
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
"author": "Mike Duncan",
"categories": ["History"]
},
{
"id": "discover-philosophize-this",
"title": "Philosophize This!",
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
"feedUrl": "https://feed.podbean.com/philosophizethis/feed.xml",
"author": "Stephen West",
"categories": ["Philosophy", "Education"]
},
{
"id": "discover-very-bad-wizards",
"title": "Very Bad Wizards",
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
"feedUrl": "https://feed.podbean.com/verybadwizards/feed.xml",
"author": "Tamler Sommers & David Pizarro",
"categories": ["Philosophy", "Science"]
},
{
"id": "discover-big-picture",
"title": "The Big Picture",
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
"feedUrl": "https://rss.art19.com/the-big-picture",
"author": "The Ringer",
"categories": ["Film", "Entertainment"]
},
{
"id": "discover-all-songs-considered",
"title": "All Songs Considered",
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
"author": "NPR Music",
"categories": ["Music"]
},
{
"id": "discover-switched-on-pop",
"title": "Switched on Pop",
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
"author": "Vox Media / Panoply",
"categories": ["Music"]
},
{
"id": "discover-hit-parade",
"title": "Hit Parade",
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
"feedUrl": "https://feeds.megaphone.fm/hitparade",
"author": "Slate",
"categories": ["Music", "History"]
},
{
"id": "discover-song-exploder",
"title": "Song Exploder",
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
"feedUrl": "https://songexploder.net/rss",
"author": "Hrishikesh Hirway",
"categories": ["Music", "Arts"]
},
{
"id": "discover-blank-check",
"title": "Blank Check with Griffin & David",
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
"feedUrl": "https://audioboom.com/channels/4278829.rss",
"author": "Griffin Newman & David Sims",
"categories": ["Film", "Comedy"]
},
{
"id": "discover-99-invisible",
"title": "99% Invisible",
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
"author": "Roman Mars",
"categories": ["Design", "Arts", "Culture"]
},
{
"id": "discover-gastropod",
"title": "Gastropod",
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
"feedUrl": "https://gastropod.com/feed",
"author": "Cynthia Graber & Nicola Twilley",
"categories": ["Food", "Science", "History"]
},
{
"id": "discover-this-american-life",
"title": "This American Life",
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
"author": "This American Life",
"categories": ["Storytelling", "Culture"]
},
{
"id": "discover-ted-talks-daily",
"title": "TED Talks Daily",
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
"author": "TED",
"categories": ["Education", "Storytelling"]
},
{
"id": "discover-tim-ferriss",
"title": "The Tim Ferriss Show",
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
"author": "Tim Ferriss",
"categories": ["Self-Improvement", "Business"]
},
{
"id": "discover-jordan-harbinger",
"title": "The Jordan Harbinger Show",
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
"author": "Jordan Harbinger",
"categories": ["Self-Improvement"]
},
{
"id": "discover-on-purpose",
"title": "On Purpose with Jay Shetty",
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
"author": "Jay Shetty",
"categories": ["Self-Improvement", "Health"]
},
{
"id": "discover-10-percent-happier",
"title": "10% Happier with Dan Harris",
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
"feedUrl": "https://rss.art19.com/ten-percent-happier",
"author": "Dan Harris",
"categories": ["Self-Improvement", "Health", "Philosophy"]
},
{
"id": "discover-school-of-greatness",
"title": "The School of Greatness",
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
"author": "Lewis Howes",
"categories": ["Self-Improvement", "Business"]
},
{
"id": "discover-sysk",
"title": "Stuff You Should Know",
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
"feedUrl": "https://rss.art19.com/stuff-you-should-know",
"author": "iHeartRadio",
"categories": ["Education", "Comedy"]
},
{
"id": "discover-in-our-time",
"title": "In Our Time",
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
"author": "BBC Radio 4",
"categories": ["History", "Education", "Philosophy"]
}
]
}

View File

@@ -1,7 +0,0 @@
- [x] Audio play can survive quit out
- [x] Discover tab does not move highlight on jk, only moves a star, My Feeds tab
moves nothing, other tabs(and main tab panel) are the correct pattern
- [x] Weird focus colors happen at times, the search panel does not get the correct pane
border color when focused for instance
- [x] Feed tab needs to fully drop the depth 1 panel - its effectively a duplication
of My Shows - Just immediately go into the full list

View File

@@ -8,13 +8,13 @@
"podtui": "./dist/index.js" "podtui": "./dist/index.js"
}, },
"scripts": { "scripts": {
"start": "bun src/index.tsx", "start": "bun --preload @opentui/solid/preload src/index.tsx",
"dev": "bun --watch src/index.tsx", "dev": "bun --preload @opentui/solid/preload --watch src/index.tsx",
"build:native": "bash scripts/build-cavacore.sh", "build:native": "bash scripts/build-cavacore.sh",
"build": "bun run build.ts", "build": "bun run build.ts",
"dist": "bun dist/index.js", "dist": "bun dist/index.js",
"test": "bun test", "test": "bun test",
"lint": "bun run lint.ts" "lint": "bun tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",

View File

@@ -19,35 +19,57 @@ mkdir -p "$OUT_DIR"
OS="$(uname -s)" OS="$(uname -s)"
ARCH="$(uname -m)" ARCH="$(uname -m)"
# Resolve fftw3 paths # Resolve fftw3 paths. The static archive lives in different places per
# platform: Homebrew (/opt/homebrew on arm64, /usr/local on Intel) and, on
# Debian/Ubuntu, the multiarch dir /usr/lib/<triplet> (e.g.
# x86_64-linux-gnu, aarch64-linux-gnu).
if [ "$OS" = "Darwin" ]; then if [ "$OS" = "Darwin" ]; then
if [ "$ARCH" = "arm64" ]; then
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
else
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
fi
LIB_EXT="dylib" LIB_EXT="dylib"
SHARED_FLAG="-dynamiclib" SHARED_FLAG="-dynamiclib"
INSTALL_NAME="-install_name @rpath/libcavacore.dylib" INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
if [ "$ARCH" = "arm64" ]; then
FFTW_HINTS="/opt/homebrew /usr/local"
else
FFTW_HINTS="/usr/local /opt/homebrew"
fi
else else
FFTW_PREFIX="${FFTW_PREFIX:-/usr}"
LIB_EXT="so" LIB_EXT="so"
SHARED_FLAG="-shared" SHARED_FLAG="-shared"
INSTALL_NAME="" INSTALL_NAME=""
FFTW_HINTS="/usr /usr/local"
fi
FFTW_PREFIX="${FFTW_PREFIX:-}"
FFTW_STATIC=""
if [ -n "$FFTW_PREFIX" ]; then
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
else
for hint in $FFTW_HINTS; do
for cand in "$hint/lib/libfftw3.a" "$hint/lib/${ARCH}-linux-gnu/libfftw3.a"; do
if [ -f "$cand" ]; then
FFTW_STATIC="$cand"
FFTW_PREFIX="$hint"
break 2
fi
done
done
fi
if [ -z "$FFTW_STATIC" ] || [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found (searched: ${FFTW_HINTS})"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
echo "or point FFTW_PREFIX at a prefix containing lib/libfftw3.a."
exit 1
fi fi
FFTW_INCLUDE="$FFTW_PREFIX/include" FFTW_INCLUDE="$FFTW_PREFIX/include"
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a" if [ ! -d "$FFTW_INCLUDE" ]; then
FFTW_INCLUDE="$FFTW_PREFIX/include/$(basename "$(dirname "$FFTW_STATIC")")"
if [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found at $FFTW_STATIC"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
exit 1
fi fi
if [ ! -f "$SRC" ]; then if [ ! -f "$SRC" ]; then
echo "Error: cavacore.c not found at $SRC" echo "Error: cavacore.c not found at $SRC"
echo "Ensure the cava submodule is initialized: git submodule update --init" echo "The cava source is vendored under cava/ (from github.com/karlstav/cava, MIT)."
exit 1 exit 1
fi fi

View File

@@ -1,28 +0,0 @@
import type { TabId } from "./Tab"
import { useTheme } from "@/context/ThemeContext"
type NavigationProps = {
activeTab: TabId
onTabSelect: (tab: TabId) => void
}
export function Navigation(props: NavigationProps) {
const { theme } = useTheme();
return (
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
<text fg={theme.text}>
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
<span> </span>
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
<span> </span>
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
<span> </span>
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
<span> </span>
{props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "}
<span> </span>
{props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "}
</text>
</box>
)
}

Binary file not shown.

View File

@@ -65,6 +65,12 @@ function DiscoverPage() {
}; };
onMount(ensureFocus); onMount(ensureFocus);
// Auto-fetch the featured-shows manifest on first mount (network failure is
// non-fatal — the list stays empty until the user hits refresh).
onMount(() => {
discoverStore.refresh().catch(() => {});
});
onMount(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id; if (depth() === 0) return categories()[i]?.id;
@@ -243,7 +249,9 @@ function DiscoverPage() {
{podcast.title} {podcast.title}
</text> </text>
<Show when={podcast.isSubscribed}> <Show when={podcast.isSubscribed}>
<text fg={index() === lf() ? theme.surface : theme.success}> <text
fg={index() === lf() ? theme.surface : theme.success}
>
[+] [+]
</text> </text>
</Show> </Show>

View File

@@ -1,18 +1,18 @@
import type { BackendName } from "../utils/audio-player" import type { BackendName } from "@/utils/audio-player";
import { useTheme } from "@/context/ThemeContext" import { useTheme } from "@/context/ThemeContext";
type PlaybackControlsProps = { type PlaybackControlsProps = {
isPlaying: boolean isPlaying: boolean;
volume: number volume: number;
speed: number speed: number;
backendName?: BackendName backendName?: BackendName;
hasAudioUrl?: boolean hasAudioUrl?: boolean;
onToggle: () => void onToggle: () => void;
onPrev: () => void onPrev: () => void;
onNext: () => void onNext: () => void;
onVolumeChange: (value: number) => void onVolumeChange: (value: number) => void;
onSpeedChange: (value: number) => void onSpeedChange: (value: number) => void;
} };
const BACKEND_LABELS: Record<BackendName, string> = { const BACKEND_LABELS: Record<BackendName, string> = {
mpv: "mpv", mpv: "mpv",
@@ -20,19 +20,41 @@ const BACKEND_LABELS: Record<BackendName, string> = {
afplay: "afplay", afplay: "afplay",
system: "system", system: "system",
none: "none", none: "none",
} };
export function PlaybackControls(props: PlaybackControlsProps) { export function PlaybackControls(props: PlaybackControlsProps) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}> <box
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}> flexDirection="row"
gap={1}
alignItems="center"
border
padding={1}
borderColor={theme.border}
>
<box
border
padding={0}
onMouseDown={props.onPrev}
borderColor={theme.border}
>
<text fg={theme.primary}>[Prev]</text> <text fg={theme.primary}>[Prev]</text>
</box> </box>
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}> <box
border
padding={0}
onMouseDown={props.onToggle}
borderColor={theme.border}
>
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text> <text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box> </box>
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}> <box
border
padding={0}
onMouseDown={props.onNext}
borderColor={theme.border}
>
<text fg={theme.primary}>[Next]</text> <text fg={theme.primary}>[Next]</text>
</box> </box>
<box flexDirection="row" gap={1} marginLeft={2}> <box flexDirection="row" gap={1} marginLeft={2}>
@@ -60,5 +82,5 @@ export function PlaybackControls(props: PlaybackControlsProps) {
</box> </box>
)} )}
</box> </box>
) );
} }

View File

@@ -59,6 +59,10 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
let frameTimer: ReturnType<typeof setInterval> | null = null; let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null; let sampleBuffer: Float64Array | null = null;
// Bar count comes from the visualizer config (set in Settings); default 64.
// Single source of truth used for cavacore init, rendering, and seek clicks.
const numBars = () => props.visualizerConfig?.bars ?? 64;
// ── Lifecycle: init cavacore once ────────────────────────────────── // ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => { const initCava = () => {
@@ -83,7 +87,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// Initialize cavacore with current resolution + any overrides // Initialize cavacore with current resolution + any overrides
const config: CavaCoreConfig = { const config: CavaCoreConfig = {
bars: 32, bars: numBars(),
sampleRate: 44100, sampleRate: 44100,
channels: 1, channels: 1,
...props.visualizerConfig, ...props.visualizerConfig,
@@ -143,9 +147,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
on( on(
[ [
audio.isPlaying, audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here () => audio.currentEpisode()?.audioUrl ?? "",
audio.speed, audio.speed,
() => 32, numBars,
], ],
([playing, url, speed]) => { ([playing, url, speed]) => {
if (playing && url) { if (playing && url) {
@@ -204,11 +208,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const renderLine = () => { const renderLine = () => {
const bars = barData(); const bars = barData();
const numBars = 32; const count = numBars();
// If no data yet, show empty placeholder // If no data yet, show empty placeholder
if (bars.length === 0) { if (bars.length === 0) {
const placeholder = ".".repeat(numBars); const placeholder = ".".repeat(count);
return ( return (
<box flexDirection="row" gap={0}> <box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text> <text fg="#3b4252">{placeholder}</text>
@@ -216,7 +220,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
); );
} }
const played = Math.floor(numBars * playedRatio()); const played = Math.floor(count * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590"; const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252"; const futureColor = "#3b4252";
@@ -239,8 +243,8 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}; };
const handleClick = (event: { x: number }) => { const handleClick = (event: { x: number }) => {
const numBars = 32; const count = numBars();
const ratio = event.x / numBars; const ratio = event.x / count;
const next = Math.max( const next = Math.max(
0, 0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)), Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
@@ -249,7 +253,12 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
}; };
return ( return (
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}> <box
border
borderColor={theme.border}
padding={1}
onMouseDown={handleClick}
>
{renderLine()} {renderLine()}
</box> </box>
); );

View File

@@ -25,6 +25,7 @@ import {
onCleanup, onCleanup,
} from "solid-js"; } from "solid-js";
import { useSearchStore } from "@/stores/search"; import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
@@ -44,6 +45,7 @@ export const SearchPaneCount = 1;
function SearchPage() { function SearchPage() {
const searchStore = useSearchStore(); const searchStore = useSearchStore();
const feedStore = useFeedStore();
const [inputValue, setInputValue] = createSignal(""); const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
@@ -60,16 +62,22 @@ function SearchPage() {
// `inputFocused` is true while the query input is being typed in. The Shell // `inputFocused` is true while the query input is being typed in. The Shell
// router yields keys to the <input> while this is true; Escape (in Shell) // router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true. // sets it false so navigation resumes; `s` (search action) sets it true.
// Depth transitions also drive it: typing is the default on the query depth. //
let prevDepth = depth(); // Typing is the default only on the query depth (0); the results depth
onMount(() => nav.setInputFocused(true)); // (1) is always list-navigation. Drive `inputFocused` straight off
// `depth()` rather than seeding it `true` on mount and patching on change:
// the depth stack persists across tab switches, so re-mounting this page
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
// j/k (yielding to a non-existent input) and only the scrollbox's native
// scroll responds.
//
// The effect only re-runs on a depth transition, so Escape (defocus) and
// `s` (refocus) at the same depth are not clobbered.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false)); onCleanup(() => nav.setInputFocused(false));
createEffect(() => { createEffect(() => {
const d = depth(); nav.setInputFocused(depth() === 0);
if (d !== prevDepth) {
nav.setInputFocused(d === 0);
prevDepth = d;
}
}); });
// ── results (depth 1) ───────────────────────────────────────────────────── // ── results (depth 1) ─────────────────────────────────────────────────────
@@ -121,6 +129,8 @@ function SearchPage() {
}; };
const handleSubscribe = (result: SearchResult) => { const handleSubscribe = (result: SearchResult) => {
// Actually add the feed to the feed store, then mark the result subscribed
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
searchStore.markSubscribed(result.podcast.id); searchStore.markSubscribed(result.podcast.id);
}; };
@@ -312,7 +322,9 @@ function SearchPage() {
{result.podcast.title} {result.podcast.title}
</text> </text>
<Show when={result.podcast.isSubscribed}> <Show when={result.podcast.isSubscribed}>
<text fg={index() === fi() ? theme.surface : theme.success}> <text
fg={index() === fi() ? theme.surface : theme.success}
>
[+] [+]
</text> </text>
</Show> </Show>

View File

@@ -14,13 +14,8 @@ const typeLabel = (sourceType?: SourceType) => {
return "Source"; return "Source";
}; };
const typeColor = (sourceType?: SourceType) => { // No module-level typeColor here — it needs the theme from the component.
if (sourceType === SourceType.API) return theme.primary; // The correct definition lives inside SourceBadge below.
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
export function SourceBadge(props: SourceBadgeProps) { export function SourceBadge(props: SourceBadgeProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const label = () => props.sourceName || props.sourceId; const label = () => props.sourceName || props.sourceId;

View File

@@ -17,7 +17,7 @@ import {
} from "../utils/app-persistence"; } from "../utils/app-persistence";
const defaultVisualizerSettings: VisualizerSettings = { const defaultVisualizerSettings: VisualizerSettings = {
bars: 32, bars: 64,
sensitivity: 1, sensitivity: 1,
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,

View File

@@ -1,15 +1,22 @@
/** /**
* Discover store for PodTUI * Discover store for PodTUI
* Manages trending/popular podcasts and category filtering * Manages trending/popular podcasts and category filtering.
*
* The featured-shows list is fetched at runtime from a JSON file hosted in the
* GitHub repo (discover/featured.json on the `master` branch), so the list
* can be updated without shipping a new release. The feed URL, de-duped set,
* and version field act as the cache key — a fresh fetch only happens when the
* version bumps or the cache window (24h) expires.
*/ */
import { createSignal } from "solid-js" import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast" import type { Podcast } from "../types/podcast";
import { useFeedStore } from "./feed";
export interface DiscoverCategory { export interface DiscoverCategory {
id: string id: string;
name: string name: string;
icon: string icon: string;
} }
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
@@ -24,168 +31,166 @@ export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "sports", name: "Sports", icon: "#" }, { id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" }, { id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" }, { id: "arts", name: "Arts", icon: "@" },
] ];
/** Mock trending podcasts */ // ── Remote featured-shows manifest ───────────────────────────────────────────
const TRENDING_PODCASTS: Podcast[] = [ // The raw GitHub URL serving discover/featured.json from the master branch.
{ // Update this file in the repo (no release needed) to refresh the list.
id: "trend-1", const FEATURED_JSON_URL =
title: "AI Today", "https://raw.githubusercontent.com/mikefreno/PodTui/master/discover/featured.json";
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
feedUrl: "https://example.com/aitoday.rss", /** Cache window for the remote featured list (24 hours) */
author: "Tech Futures", const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
categories: ["Technology", "Science"],
/** Shape of a single entry in the remote JSON */
interface FeaturedEntry {
id: string;
title: string;
description: string;
feedUrl: string;
author?: string;
categories?: string[];
}
/** Shape of the remote JSON manifest */
interface FeaturedManifest {
version: number;
podcasts: FeaturedEntry[];
}
/** Convert a JSON entry to a runtime Podcast (adding derived fields) */
function entryToPodcast(entry: FeaturedEntry): Podcast {
return {
id: entry.id,
title: entry.title,
description: entry.description,
feedUrl: entry.feedUrl,
author: entry.author,
categories: entry.categories ?? [],
coverUrl: undefined, coverUrl: undefined,
lastUpdated: new Date(), lastUpdated: new Date(),
isSubscribed: false, isSubscribed: false,
}, };
{ }
id: "trend-2",
title: "The History Hour", /** Reconcile isSubscribed state across the discover list against the feed store */
description: "Fascinating stories from history that shaped our world today.", function syncSubscriptionState(
feedUrl: "https://example.com/historyhour.rss", podcasts: Podcast[],
author: "History Channel", subscribedUrls: Set<string>,
categories: ["Education", "History"], subscribedIds: Set<string>,
lastUpdated: new Date(), ): Podcast[] {
isSubscribed: false, return podcasts.map((p) => ({
}, ...p,
{ isSubscribed: subscribedUrls.has(p.feedUrl) || subscribedIds.has(p.id),
id: "trend-3", }));
title: "Comedy Gold", }
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
feedUrl: "https://example.com/comedygold.rss",
author: "Laugh Factory",
categories: ["Comedy", "Entertainment"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-4",
title: "Market Watch",
description: "Daily financial news, stock analysis, and investing tips.",
feedUrl: "https://example.com/marketwatch.rss",
author: "Finance Daily",
categories: ["Business", "News"],
lastUpdated: new Date(),
isSubscribed: true,
},
{
id: "trend-5",
title: "Science Weekly",
description: "Breaking science news and in-depth analysis of the latest research.",
feedUrl: "https://example.com/scienceweekly.rss",
author: "Science Network",
categories: ["Science", "Education"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-6",
title: "True Crime Files",
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
feedUrl: "https://example.com/truecrime.rss",
author: "Crime Network",
categories: ["True Crime", "Documentary"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-7",
title: "Wellness Journey",
description: "Tips for mental and physical health, meditation, and mindful living.",
feedUrl: "https://example.com/wellness.rss",
author: "Health Media",
categories: ["Health", "Self-Help"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-8",
title: "Sports Talk Live",
description: "Live commentary, analysis, and interviews from the world of sports.",
feedUrl: "https://example.com/sportstalk.rss",
author: "Sports Network",
categories: ["Sports", "News"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-9",
title: "Creative Minds",
description: "Interviews with artists, designers, and creative professionals.",
feedUrl: "https://example.com/creativeminds.rss",
author: "Arts Weekly",
categories: ["Arts", "Culture"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-10",
title: "Dev Talk",
description: "Software development, programming tutorials, and tech career advice.",
feedUrl: "https://example.com/devtalk.rss",
author: "Code Academy",
categories: ["Technology", "Education"],
lastUpdated: new Date(),
isSubscribed: true,
},
]
/** Create discover store */ /** Create discover store */
export function createDiscoverStore() { export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all") const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false) const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS) const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0;
/** Reconcile local isSubscribed flags with the feed store */
const syncSubscriptions = () => {
const feedStore = useFeedStore();
const feeds = feedStore.feeds();
const urls = new Set(feeds.map((f) => f.podcast.feedUrl));
const ids = new Set(feeds.map((f) => f.podcast.id));
setPodcasts((prev) => syncSubscriptionState(prev, urls, ids));
};
/** Fetch the featured-shows manifest from GitHub if stale */
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
return;
}
const resp = await fetch(FEATURED_JSON_URL, {
headers: { "User-Agent": "PodTUI/1.0" },
});
if (!resp.ok) {
syncSubscriptions();
return;
}
const manifest = (await resp.json()) as FeaturedManifest;
if (!manifest?.podcasts?.length) {
syncSubscriptions();
return;
}
// Build the podcast list from the manifest entries
const fetched = manifest.podcasts.map(entryToPodcast);
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
} finally {
setIsLoading(false);
}
};
/** Get filtered podcasts by category */ /** Get filtered podcasts by category */
const filteredPodcasts = () => { const filteredPodcasts = () => {
const category = selectedCategory() const category = selectedCategory();
if (category === "all") { if (category === "all") {
return podcasts() return podcasts();
} }
return podcasts().filter((p) => { return podcasts().filter((p) => {
const cats = p.categories?.map((c) => c.toLowerCase()) ?? [] const cats = p.categories?.map((c) => c.toLowerCase()) ?? [];
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " "))) return cats.some((c) =>
}) c.includes(category.toLowerCase().replace("-", " ")),
} );
});
};
/** Subscribe to a podcast */ /** Subscribe to a podcast */
const subscribe = (podcastId: string) => { const subscribe = (podcastId: string) => {
setPodcasts((prev) => const podcast = podcasts().find((p) => p.id === podcastId);
prev.map((p) => if (podcast) {
p.id === podcastId ? { ...p, isSubscribed: true } : p // Actually add the feed to the feed store
) const feedStore = useFeedStore();
) feedStore.addFeed(podcast, "discover").catch(() => {});
} }
setPodcasts((prev) =>
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: true } : p)),
);
};
/** Unsubscribe from a podcast */ /** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => { const unsubscribe = (podcastId: string) => {
setPodcasts((prev) => const podcast = podcasts().find((p) => p.id === podcastId);
prev.map((p) => if (podcast) {
p.id === podcastId ? { ...p, isSubscribed: false } : p // Remove the feed from the feed store
) const feedStore = useFeedStore();
) feedStore.removeFeedByUrl(podcast.feedUrl);
} }
setPodcasts((prev) =>
prev.map((p) => (p.id === podcastId ? { ...p, isSubscribed: false } : p)),
);
};
/** Toggle subscription */ /** Toggle subscription */
const toggleSubscription = (podcastId: string) => { const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId) const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) { if (podcast?.isSubscribed) {
unsubscribe(podcastId) unsubscribe(podcastId);
} else { } else {
subscribe(podcastId) subscribe(podcastId);
}
}
/** Refresh trending podcasts (mock) */
const refresh = async () => {
setIsLoading(true)
// Simulate network delay
await new Promise((r) => setTimeout(r, 500))
// In real app, would fetch from API
setIsLoading(false)
} }
};
return { return {
// State // State
@@ -201,15 +206,15 @@ export function createDiscoverStore() {
unsubscribe, unsubscribe,
toggleSubscription, toggleSubscription,
refresh, refresh,
} };
} }
/** Singleton discover store */ /** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() { export function useDiscoverStore() {
if (!discoverStoreInstance) { if (!discoverStoreInstance) {
discoverStoreInstance = createDiscoverStore() discoverStoreInstance = createDiscoverStore();
} }
return discoverStoreInstance return discoverStoreInstance;
} }

View File

@@ -7,8 +7,8 @@ import { createSignal } from "solid-js";
import { FeedVisibility } from "../types/feed"; import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"; import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast"; import type { Podcast } from "../types/podcast";
import type { Episode, EpisodeStatus } from "../types/episode"; import type { Episode } from "../types/episode";
import type { PodcastSource, SourceType } from "../types/source"; import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source"; import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser"; import { parseRSSFeed } from "../api/rss-parser";
import { import {
@@ -69,7 +69,11 @@ export function createFeedStore() {
result = result.filter((feed) => feed.visibility === f.visibility); result = result.filter((feed) => feed.visibility === f.visibility);
} else if (f.visibility === "all") { } else if (f.visibility === "all") {
// Only show private feeds if authenticated // Only show private feeds if authenticated
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated); result = result.filter(
(feed) =>
feed.visibility === FeedVisibility.PUBLIC ||
authStore.isAuthenticated,
);
} }
// Filter by source // Filter by source
@@ -184,12 +188,22 @@ export function createFeedStore() {
} }
}; };
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
/** Add a new feed and auto-fetch latest 20 episodes */ /** Add a new feed and auto-fetch latest 20 episodes */
const addFeed = async ( const addFeed = async (
podcast: Podcast, podcast: Podcast,
sourceId: string, sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC, visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => { ): Promise<Feed | null> => {
// Guard: don't add a feed we already have (matched by feedUrl)
if (hasFeedByUrl(podcast.feedUrl)) {
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
}
const feedId = crypto.randomUUID(); const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes( const episodes = await fetchEpisodes(
podcast.feedUrl, podcast.feedUrl,
@@ -300,6 +314,20 @@ export function createFeedStore() {
}); });
}; };
/** Remove a feed by its RSS URL (for sources that match by URL, not ID) */
const removeFeedByUrl = (feedUrl: string) => {
const feed = feeds().find((f) => f.podcast.feedUrl === feedUrl);
if (feed) {
fullEpisodeCache.delete(feed.id);
episodeLoadCount.delete(feed.id);
setFeeds((prev) => {
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
saveFeeds(updated);
return updated;
});
}
};
/** Update a feed */ /** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => { const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => { setFeeds((prev) => {
@@ -470,7 +498,9 @@ export function createFeedStore() {
setFilter, setFilter,
setSelectedFeedId, setSelectedFeedId,
addFeed, addFeed,
hasFeedByUrl,
removeFeed, removeFeed,
removeFeedByUrl,
updateFeed, updateFeed,
togglePinned, togglePinned,
refreshFeed, refreshFeed,

View File

@@ -62,7 +62,7 @@ export type DesktopTheme = {
}; };
export type VisualizerSettings = { export type VisualizerSettings = {
/** Number of frequency bars (8128, default: 32) */ /** Number of frequency bars (8128, default: 64) */
bars: number; bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
sensitivity: number; sensitivity: number;

View File

@@ -180,12 +180,14 @@ export function CommandProvider(props: ParentProps) {
const dialog = useDialog(); const dialog = useDialog();
const keybind = useKeybinds(); const keybind = useKeybinds();
// Open command palette on ctrl+p or command_list keybind // Open the command palette via the `command` keybind (bound to `:` in
// keybinds.jsonc). The old hardcoded "command_list" name was never a
// canonical action, so the palette was unreachable dead code.
useKeyboard((evt) => { useKeyboard((evt) => {
if (value.suspended()) return; if (value.suspended()) return;
if (dialog.isOpen) return; if (dialog.isOpen) return;
if (evt.defaultPrevented) return; if (evt.defaultPrevented) return;
if (keybind.match("command_list", evt)) { if (keybind.match("command", evt)) {
evt.preventDefault(); evt.preventDefault();
value.show(); value.show();
return; return;
@@ -279,7 +281,11 @@ function CommandDialog(props: {
</box> </box>
{/* Command list */} {/* Command list */}
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}> <box
flexDirection="column"
maxHeight={maxHeight}
borderColor={theme.border}
>
<For each={filteredOptions().slice(0, 10)}> <For each={filteredOptions().slice(0, 10)}>
{(option, index) => ( {(option, index) => (
<SelectableBox <SelectableBox

View File

@@ -11,18 +11,18 @@
*/ */
/** PCM output format constants */ /** PCM output format constants */
const SAMPLE_RATE = 44100 const SAMPLE_RATE = 44100;
const CHANNELS = 1 const CHANNELS = 1;
const BYTES_PER_SAMPLE = 2 // s16le const BYTES_PER_SAMPLE = 2; // s16le
/** How many samples to buffer (~1 second) */ /** How many samples to buffer (~1 second) */
const RING_BUFFER_SAMPLES = SAMPLE_RATE const RING_BUFFER_SAMPLES = SAMPLE_RATE;
export interface AudioStreamReaderOptions { export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */ /** Audio URL or file path to decode */
url: string url: string;
/** Sample rate (default: 44100) */ /** Sample rate (default: 44100) */
sampleRate?: number sampleRate?: number;
} }
/** /**
@@ -30,32 +30,32 @@ export interface AudioStreamReaderOptions {
* Each start() increments this; the read loop checks it to know * Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out. * if it's been superseded and should bail out.
*/ */
let globalGeneration = 0 let globalGeneration = 0;
export class AudioStreamReader { export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null private proc: ReturnType<typeof Bun.spawn> | null = null;
private ringBuffer: Float64Array private ringBuffer: Float64Array;
private writePos = 0 private writePos = 0;
private totalSamplesWritten = 0 private totalSamplesWritten = 0;
private _running = false private _running = false;
private generation = 0 private generation = 0;
readonly url: string readonly url: string;
private sampleRate: number private sampleRate: number;
constructor(options: AudioStreamReaderOptions) { constructor(options: AudioStreamReaderOptions) {
this.url = options.url this.url = options.url;
this.sampleRate = options.sampleRate ?? SAMPLE_RATE this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES) this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
} }
/** Whether the reader is actively reading samples. */ /** Whether the reader is actively reading samples. */
get running(): boolean { get running(): boolean {
return this._running return this._running;
} }
/** Total number of samples written since start(). */ /** Total number of samples written since start(). */
get samplesWritten(): number { get samplesWritten(): number {
return this.totalSamplesWritten return this.totalSamplesWritten;
} }
/** /**
@@ -72,72 +72,88 @@ export class AudioStreamReader {
*/ */
start(startPosition = 0, speed = 1): void { start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running // Always kill the previous process first — no early return on _running
this.killProcess() this.killProcess();
if (!Bun.which("ffmpeg")) { if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization") throw new Error("ffmpeg not found — required for audio visualization");
} }
// Increment generation so any lingering read loop from a previous // Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit. // start() will see a mismatch and exit.
this.generation = ++globalGeneration this.generation = ++globalGeneration;
const args = [ const args = [
"ffmpeg", "ffmpeg",
"-loglevel", "quiet", "-loglevel",
"-reconnect", "1", "quiet",
"-reconnect_streamed", "1", // Read input at native frame rate so decoded PCM stays in sync with
"-reconnect_delay_max", "5", // real-time playback. Without -re, ffmpeg greedily decodes the whole
] // file as fast as possible: the ring buffer fills with audio seconds
// ahead of the player (laggy bars), then the process exits when it
// hits EOF (bars freeze ~10s in).
"-re",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
];
// Seek before input for network efficiency // Seek before input for network efficiency
if (startPosition > 0) { if (startPosition > 0) {
args.push("-ss", String(startPosition)) args.push("-ss", String(startPosition));
} }
args.push("-i", this.url) args.push("-i", this.url);
// Apply speed via atempo filter if not 1x. // Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes. // ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) { if (speed !== 1 && speed > 0) {
args.push("-af", buildAtempoChain(speed)) args.push("-af", buildAtempoChain(speed));
} }
args.push( args.push(
"-ac", String(CHANNELS), "-ac",
"-ar", String(this.sampleRate), String(CHANNELS),
"-f", "s16le", "-ar",
"-acodec", "pcm_s16le", String(this.sampleRate),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-", "-",
) );
this.proc = Bun.spawn(args, { this.proc = Bun.spawn(args, {
stdout: "pipe", stdout: "pipe",
stderr: "ignore", stderr: "ignore",
stdin: "ignore", stdin: "ignore",
}) });
this._running = true this._running = true;
this.writePos = 0 this.writePos = 0;
this.totalSamplesWritten = 0 this.totalSamplesWritten = 0;
// Capture generation for this run // Capture generation for this run
const myGeneration = this.generation const myGeneration = this.generation;
// Start async reading loop // Start async reading loop
this.readLoop(myGeneration) this.readLoop(myGeneration);
// Detect process exit // Detect process exit
this.proc.exited.then(() => { this.proc.exited
.then(() => {
// Only clear _running if this is still the current generation // Only clear _running if this is still the current generation
if (this.generation === myGeneration) { if (this.generation === myGeneration) {
this._running = false this._running = false;
}
}).catch(() => {
if (this.generation === myGeneration) {
this._running = false
} }
}) })
.catch(() => {
if (this.generation === myGeneration) {
this._running = false;
}
});
} }
/** /**
@@ -148,21 +164,27 @@ export class AudioStreamReader {
* @returns Number of samples written to `out`. * @returns Number of samples written to `out`.
*/ */
read(out: Float64Array): number { read(out: Float64Array): number {
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length) const available = Math.min(
if (available <= 0) return 0 out.length,
this.totalSamplesWritten,
this.ringBuffer.length,
);
if (available <= 0) return 0;
// Read the most recent `available` samples from the ring buffer // Read the most recent `available` samples from the ring buffer
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length const readStart =
(this.writePos - available + this.ringBuffer.length) %
this.ringBuffer.length;
if (readStart + available <= this.ringBuffer.length) { if (readStart + available <= this.ringBuffer.length) {
out.set(this.ringBuffer.subarray(readStart, readStart + available)) out.set(this.ringBuffer.subarray(readStart, readStart + available));
} else { } else {
const firstChunk = this.ringBuffer.length - readStart const firstChunk = this.ringBuffer.length - readStart;
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length)) out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk) out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
} }
return available return available;
} }
/** /**
@@ -171,59 +193,67 @@ export class AudioStreamReader {
*/ */
stop(): void { stop(): void {
// Bump generation to invalidate any running read loop // Bump generation to invalidate any running read loop
this.generation = ++globalGeneration this.generation = ++globalGeneration;
this._running = false this._running = false;
this.killProcess() this.killProcess();
this.writePos = 0 this.writePos = 0;
this.totalSamplesWritten = 0 this.totalSamplesWritten = 0;
} }
/** /**
* Restart the reader at a new position and/or speed. * Restart the reader at a new position and/or speed.
*/ */
restart(startPosition = 0, speed = 1): void { restart(startPosition = 0, speed = 1): void {
this.start(startPosition, speed) this.start(startPosition, speed);
} }
/** Kill the ffmpeg process without touching generation/state. */ /** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void { private killProcess(): void {
if (this.proc) { if (this.proc) {
try { this.proc.kill() } catch { /* ignore */ } try {
this.proc = null this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
} }
} }
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */ /** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
private async readLoop(myGeneration: number): Promise<void> { private async readLoop(myGeneration: number): Promise<void> {
const stdout = this.proc?.stdout const stdout = this.proc?.stdout;
if (!stdout || typeof stdout === "number") return if (!stdout || typeof stdout === "number") return;
const reader = (stdout as ReadableStream<Uint8Array>).getReader() const reader = (stdout as ReadableStream<Uint8Array>).getReader();
try { try {
while (this.generation === myGeneration) { while (this.generation === myGeneration) {
const { done, value } = await reader.read() const { done, value } = await reader.read();
if (done || this.generation !== myGeneration) break if (done || this.generation !== myGeneration) break;
if (!value || value.byteLength === 0) continue if (!value || value.byteLength === 0) continue;
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE) const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
if (sampleCount === 0) continue if (sampleCount === 0) continue;
const int16View = new Int16Array( const int16View = new Int16Array(
value.buffer, value.buffer,
value.byteOffset, value.byteOffset,
sampleCount, sampleCount,
) );
for (let i = 0; i < sampleCount; i++) { for (let i = 0; i < sampleCount; i++) {
this.ringBuffer[this.writePos] = int16View[i] this.ringBuffer[this.writePos] = int16View[i];
this.writePos = (this.writePos + 1) % this.ringBuffer.length this.writePos = (this.writePos + 1) % this.ringBuffer.length;
this.totalSamplesWritten++ this.totalSamplesWritten++;
} }
} }
} catch { } catch {
// Stream ended or process killed — expected during stop() // Stream ended or process killed — expected during stop()
} finally { } finally {
try { reader.releaseLock() } catch { /* ignore */ } try {
reader.releaseLock();
} catch {
/* ignore */
}
} }
} }
} }
@@ -234,18 +264,18 @@ export class AudioStreamReader {
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5). * multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
*/ */
function buildAtempoChain(speed: number): string { function buildAtempoChain(speed: number): string {
const parts: string[] = [] const parts: string[] = [];
let remaining = Math.max(0.25, Math.min(4, speed)) let remaining = Math.max(0.25, Math.min(4, speed));
while (remaining > 100) { while (remaining > 100) {
parts.push("atempo=100.0") parts.push("atempo=100.0");
remaining /= 100 remaining /= 100;
} }
while (remaining < 0.5) { while (remaining < 0.5) {
parts.push("atempo=0.5") parts.push("atempo=0.5");
remaining /= 0.5 remaining /= 0.5;
} }
parts.push(`atempo=${remaining}`) parts.push(`atempo=${remaining}`);
return parts.join(",") return parts.join(",");
} }

View File

@@ -16,27 +16,29 @@
* ``` * ```
*/ */
import { dlopen, FFIType, ptr } from "bun:ffi" import { dlopen, FFIType, ptr } from "bun:ffi";
import { existsSync } from "fs" import { existsSync } from "fs";
import { join, dirname } from "path" import { join, dirname } from "path";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
export interface CavaCoreConfig { export interface CavaCoreConfig {
/** Number of frequency bars (default: 32) */ /** Number of frequency bars (default: 32) */
bars?: number bars?: number;
/** Audio sample rate in Hz (default: 44100) */ /** Audio sample rate in Hz (default: 44100) */
sampleRate?: number sampleRate?: number;
/** Number of audio channels (default: 1 = mono) */ /** Number of audio channels (default: 1 = mono) */
channels?: number channels?: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */ /** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
autosens?: number autosens?: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */ /** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction?: number noiseReduction?: number;
/** Low frequency cutoff in Hz (default: 50) */ /** Low frequency cutoff in Hz (default: 50) */
lowCutOff?: number lowCutOff?: number;
/** High frequency cutoff in Hz (default: 10000) */ /** High frequency cutoff in Hz (default: 10000) */
highCutOff?: number highCutOff?: number;
/** Output scaling mode: 0 = linear (default), 1 = decibel */
scalingMode?: number;
} }
const DEFAULTS: Required<CavaCoreConfig> = { const DEFAULTS: Required<CavaCoreConfig> = {
@@ -47,20 +49,25 @@ const DEFAULTS: Required<CavaCoreConfig> = {
noiseReduction: 0.77, noiseReduction: 0.77,
lowCutOff: 50, lowCutOff: 50,
highCutOff: 10000, highCutOff: 10000,
} scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void } type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
};
// ── Library resolution ─────────────────────────────────────────────── // ── Library resolution ───────────────────────────────────────────────
function findLibrary(): string | null { function findLibrary(): string | null {
const platform = process.platform const platform = process.platform;
const libName = platform === "darwin" const libName =
platform === "darwin"
? "libcavacore.dylib" ? "libcavacore.dylib"
: platform === "win32" : platform === "win32"
? "cavacore.dll" ? "cavacore.dll"
: "libcavacore.so" : "libcavacore.so";
// Candidate paths, in priority order: // Candidate paths, in priority order:
// 1. src/native/ (development) // 1. src/native/ (development)
@@ -70,39 +77,39 @@ function findLibrary(): string | null {
join(import.meta.dir, "..", "native", libName), join(import.meta.dir, "..", "native", libName),
join(dirname(process.execPath), libName), join(dirname(process.execPath), libName),
join(process.cwd(), "dist", libName), join(process.cwd(), "dist", libName),
] ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (existsSync(candidate)) return candidate if (existsSync(candidate)) return candidate;
} }
return null return null;
} }
// ── CavaCore class ─────────────────────────────────────────────────── // ── CavaCore class ───────────────────────────────────────────────────
export class CavaCore { export class CavaCore {
private lib: CavaLib private lib: CavaLib;
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null;
private inputBuffer: Float64Array | null = null private inputBuffer: Float64Array | null = null;
private outputBuffer: Float64Array | null = null private outputBuffer: Float64Array | null = null;
private _bars = 0 private _bars = 0;
private _channels = 1 private _channels = 1;
private _destroyed = false private _destroyed = false;
/** Use loadCavaCore() instead of constructing directly. */ /** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) { constructor(lib: CavaLib) {
this.lib = lib this.lib = lib;
} }
/** Number of frequency bars configured. */ /** Number of frequency bars configured. */
get bars(): number { get bars(): number {
return this._bars return this._bars;
} }
/** Whether this instance has been initialized (and not yet destroyed). */ /** Whether this instance has been initialized (and not yet destroyed). */
get isReady(): boolean { get isReady(): boolean {
return this.plan !== null && !this._destroyed return this.plan !== null && !this._destroyed;
} }
/** /**
@@ -112,12 +119,12 @@ export class CavaCore {
*/ */
init(config: CavaCoreConfig = {}): void { init(config: CavaCoreConfig = {}): void {
if (this.plan) { if (this.plan) {
this.destroy() this.destroy();
} }
const cfg = { ...DEFAULTS, ...config } const cfg = { ...DEFAULTS, ...config };
this._bars = cfg.bars this._bars = cfg.bars;
this._channels = cfg.channels this._channels = cfg.channels;
this.plan = this.lib.symbols.cava_init( this.plan = this.lib.symbols.cava_init(
cfg.bars, cfg.bars,
@@ -127,15 +134,16 @@ export class CavaCore {
cfg.noiseReduction, cfg.noiseReduction,
cfg.lowCutOff, cfg.lowCutOff,
cfg.highCutOff, cfg.highCutOff,
) cfg.scalingMode,
);
if (!this.plan) { if (!this.plan) {
throw new Error("cava_init returned null — initialization failed") throw new Error("cava_init returned null — initialization failed");
} }
// Pre-allocate output buffer (bars * channels) // Pre-allocate output buffer (bars * channels)
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels) this.outputBuffer = new Float64Array(cfg.bars * cfg.channels);
this._destroyed = false this._destroyed = false;
} }
/** /**
@@ -148,23 +156,23 @@ export class CavaCore {
*/ */
execute(samples: Float64Array): Float64Array { execute(samples: Float64Array): Float64Array {
if (!this.plan || !this.outputBuffer) { if (!this.plan || !this.outputBuffer) {
throw new Error("CavaCore not initialized — call init() first") throw new Error("CavaCore not initialized — call init() first");
} }
// Reuse input buffer if same size, otherwise allocate new // Reuse input buffer if same size, otherwise allocate new
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) { if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
this.inputBuffer = new Float64Array(samples.length) this.inputBuffer = new Float64Array(samples.length);
} }
this.inputBuffer.set(samples) this.inputBuffer.set(samples);
this.lib.symbols.cava_execute( this.lib.symbols.cava_execute(
ptr(this.inputBuffer), ptr(this.inputBuffer),
samples.length, samples.length,
ptr(this.outputBuffer), ptr(this.outputBuffer),
this.plan, this.plan,
) );
return this.outputBuffer return this.outputBuffer;
} }
/** /**
@@ -173,12 +181,12 @@ export class CavaCore {
*/ */
destroy(): void { destroy(): void {
if (this.plan && !this._destroyed) { if (this.plan && !this._destroyed) {
this.lib.symbols.cava_destroy(this.plan) this.lib.symbols.cava_destroy(this.plan);
this.plan = null this.plan = null;
this._destroyed = true this._destroyed = true;
} }
this.inputBuffer = null this.inputBuffer = null;
this.outputBuffer = null this.outputBuffer = null;
} }
} }
@@ -191,8 +199,8 @@ export class CavaCore {
*/ */
export function loadCavaCore(): CavaCore | null { export function loadCavaCore(): CavaCore | null {
try { try {
const libPath = findLibrary() const libPath = findLibrary();
if (!libPath) return null if (!libPath) return null;
const lib = dlopen(libPath, { const lib = dlopen(libPath, {
cava_init: { cava_init: {
@@ -204,6 +212,7 @@ export function loadCavaCore(): CavaCore | null {
FFIType.double, // noise_reduction FFIType.double, // noise_reduction
FFIType.i32, // low_cut_off FFIType.i32, // low_cut_off
FFIType.i32, // high_cut_off FFIType.i32, // high_cut_off
FFIType.i32, // scaling_mode
], ],
returns: FFIType.ptr, returns: FFIType.ptr,
}, },
@@ -220,11 +229,11 @@ export function loadCavaCore(): CavaCore | null {
args: [FFIType.ptr], // plan args: [FFIType.ptr], // plan
returns: FFIType.void, returns: FFIType.void,
}, },
}) });
return new CavaCore(lib as CavaLib) return new CavaCore(lib as CavaLib);
} catch { } catch {
// Library load failed — missing dylib, wrong arch, etc. // Library load failed — missing dylib, wrong arch, etc.
return null return null;
} }
} }

View File

@@ -2,14 +2,29 @@
* Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy. * Smoke test: load libcavacore.dylib via bun:ffi, init → execute → destroy.
* Run: bun tests/cavacore-smoke.ts * Run: bun tests/cavacore-smoke.ts
*/ */
import { dlopen, FFIType, ptr } from "bun:ffi" import { dlopen, FFIType, ptr } from "bun:ffi";
import { join } from "path" import { join } from "path";
const libPath = join(import.meta.dir, "..", "src", "native", "libcavacore.dylib") const libPath = join(
import.meta.dir,
"..",
"src",
"native",
"libcavacore.dylib",
);
const lib = dlopen(libPath, { const lib = dlopen(libPath, {
cava_init: { cava_init: {
args: [FFIType.i32, FFIType.u32, FFIType.i32, FFIType.i32, FFIType.double, FFIType.i32, FFIType.i32], args: [
FFIType.i32,
FFIType.u32,
FFIType.i32,
FFIType.i32,
FFIType.double,
FFIType.i32,
FFIType.i32,
FFIType.i32,
],
returns: FFIType.ptr, returns: FFIType.ptr,
}, },
cava_execute: { cava_execute: {
@@ -20,39 +35,52 @@ const lib = dlopen(libPath, {
args: [FFIType.ptr], args: [FFIType.ptr],
returns: FFIType.void, returns: FFIType.void,
}, },
}) });
const bars = 10 const bars = 10;
const rate = 44100 const rate = 44100;
const channels = 1 const channels = 1;
// Init // Init
const plan = lib.symbols.cava_init(bars, rate, channels, 1, 0.77, 50, 10000) const plan = lib.symbols.cava_init(
bars,
rate,
channels,
1,
0.77,
50,
10000,
0 /* CAVA_SCALING_LINEAR */,
);
if (!plan) { if (!plan) {
console.error("FAIL: cava_init returned null") console.error("FAIL: cava_init returned null");
process.exit(1) process.exit(1);
} }
console.log("cava_init OK, plan pointer:", plan) console.log("cava_init OK, plan pointer:", plan);
// Generate a 200Hz sine wave test signal // Generate a 200Hz sine wave test signal
const bufferSize = 512 const bufferSize = 512;
const cavaIn = new Float64Array(bufferSize) const cavaIn = new Float64Array(bufferSize);
const cavaOut = new Float64Array(bars * channels) const cavaOut = new Float64Array(bars * channels);
for (let k = 0; k < 100; k++) { for (let k = 0; k < 100; k++) {
for (let n = 0; n < bufferSize; n++) { for (let n = 0; n < bufferSize; n++) {
cavaIn[n] = Math.sin(2 * Math.PI * 200 / rate * (n + k * bufferSize)) * 20000 cavaIn[n] =
Math.sin(((2 * Math.PI * 200) / rate) * (n + k * bufferSize)) * 20000;
} }
lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan) lib.symbols.cava_execute(ptr(cavaIn), bufferSize, ptr(cavaOut), plan);
} }
console.log("cava_execute OK, output:", Array.from(cavaOut).map(v => v.toFixed(3))) console.log(
"cava_execute OK, output:",
Array.from(cavaOut).map((v) => v.toFixed(3)),
);
// Check that bar 2 (200Hz) has the peak // Check that bar 2 (200Hz) has the peak
const maxIdx = cavaOut.indexOf(Math.max(...cavaOut)) const maxIdx = cavaOut.indexOf(Math.max(...cavaOut));
console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`) console.log(`Peak at bar ${maxIdx} (expected ~2 for 200Hz)`);
// Destroy // Destroy
lib.symbols.cava_destroy(plan) lib.symbols.cava_destroy(plan);
console.log("cava_destroy OK") console.log("cava_destroy OK");
console.log("\nSMOKE TEST PASSED") console.log("\nSMOKE TEST PASSED");

View File

@@ -106,7 +106,12 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
await new Promise((r) => setTimeout(r, 40)); await new Promise((r) => setTimeout(r, 40));
} }
const spans = setup.captureSpans() as unknown as Frame; const spans = setup.captureSpans() as unknown as Frame;
return { spans, destroy: () => setup.renderer.destroy() }; return {
spans,
destroy: async () => {
setup.renderer.destroy();
},
};
} }
const cleanups: (() => void | Promise<void>)[] = []; const cleanups: (() => void | Promise<void>)[] = [];

View File

@@ -4,7 +4,7 @@
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "preserve", "jsx": "react-jsx",
"jsxImportSource": "@opentui/solid", "jsxImportSource": "@opentui/solid",
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,