galdr 0.1.0: terminal grid synthesizer (MIDI in, PipeWire out)
Signal flows downward through single-glyph unit generators; each MIDI note voices the whole grid; the mix renders low-latency to PipeWire (reusing the native path from spectrust). Grid edits recompile and hot-swap without dropping audio. Pairs with orca (note/CC source) and spectrust (visualizer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLUwWE2KmFPzhKaf67tWbx
This commit is contained in:
@@ -0,0 +1,174 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x86_64
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
sccache_dir: /sccache
|
||||||
|
- arch: aarch64
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
runs-on: aarch64
|
||||||
|
sccache_dir: /var/lib/gitea-runner/sccache
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.runs-on }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
# Compiled-crate cache via sccache. x86_64 uses /sccache (a persistent
|
||||||
|
# host dir bind-mounted into the job container by the runner config);
|
||||||
|
# aarch64 runs on the klokka host and uses a runner-owned dir.
|
||||||
|
- name: Set up sccache
|
||||||
|
run: |
|
||||||
|
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||||
|
echo "SCCACHE_DIR=${{ matrix.sccache_dir }}" >> "$GITHUB_ENV"
|
||||||
|
mkdir -p "${{ matrix.sccache_dir }}"
|
||||||
|
if ! command -v sccache >/dev/null 2>&1; then
|
||||||
|
V=0.8.2
|
||||||
|
curl -sSL "https://github.com/mozilla/sccache/releases/download/v${V}/sccache-v${V}-x86_64-unknown-linux-musl.tar.gz" | tar -xz
|
||||||
|
sudo install -m0755 "sccache-v${V}-x86_64-unknown-linux-musl/sccache" /usr/local/bin/sccache
|
||||||
|
fi
|
||||||
|
sccache --version
|
||||||
|
|
||||||
|
- name: Ensure build deps (libpipewire for output, alsa for MIDI, clang for bindgen)
|
||||||
|
run: |
|
||||||
|
if command -v apt-get >/dev/null; then
|
||||||
|
# Ephemeral container runner: safe to install into it.
|
||||||
|
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y libpipewire-0.3-dev libspa-0.2-dev libasound2-dev clang libclang-dev pkg-config
|
||||||
|
else
|
||||||
|
# Bare/host runner: never mutate it - require deps preinstalled.
|
||||||
|
missing=""
|
||||||
|
pkg-config --exists libpipewire-0.3 2>/dev/null || missing="$missing libpipewire(+dev headers)"
|
||||||
|
pkg-config --exists alsa 2>/dev/null || missing="$missing alsa-lib(+dev headers)"
|
||||||
|
command -v clang >/dev/null || missing="$missing clang"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error::bare runner missing build deps:$missing — install once on the host; CI does not modify bare runners"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "bare runner already has libpipewire + alsa + clang"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Package
|
||||||
|
run: |
|
||||||
|
TAG=${{ gitea.ref_name }}
|
||||||
|
TARBALL="galdr-${TAG}-${{ matrix.arch }}.tar.gz"
|
||||||
|
mkdir pkg
|
||||||
|
cp target/${{ matrix.target }}/release/galdr pkg/
|
||||||
|
cp LICENSE pkg/
|
||||||
|
tar -czf "${TARBALL}" -C pkg .
|
||||||
|
sha256sum "${TARBALL}" > "${TARBALL}.sha256"
|
||||||
|
echo "TARBALL=${TARBALL}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
run: |
|
||||||
|
curl -sX POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
|
||||||
|
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\"}" \
|
||||||
|
--fail-with-body || true
|
||||||
|
|
||||||
|
- name: Upload assets
|
||||||
|
run: |
|
||||||
|
RELEASE_ID=$(curl -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/tags/${{ gitea.ref_name }}" \
|
||||||
|
| jq -r '.id')
|
||||||
|
|
||||||
|
for FILE in "${{ env.TARBALL }}" "${{ env.TARBALL }}.sha256"; do
|
||||||
|
EXISTING=$(curl -s \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| jq -r ".[] | select(.name == \"${FILE}\") | .id")
|
||||||
|
for AID in $EXISTING; do
|
||||||
|
curl -sX DELETE \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets/${AID}"
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -sX POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}" \
|
||||||
|
--data-binary "@${FILE}" --fail-with-body
|
||||||
|
done
|
||||||
|
|
||||||
|
update-aur:
|
||||||
|
needs: build
|
||||||
|
runs-on: aarch64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute checksums and update PKGBUILD
|
||||||
|
run: |
|
||||||
|
TAG=${{ gitea.ref_name }}
|
||||||
|
BASE="${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/${TAG}"
|
||||||
|
SUM_X86=$(curl -sL "${BASE}/galdr-${TAG}-x86_64.tar.gz" | sha256sum | cut -d' ' -f1)
|
||||||
|
SUM_AARCH=$(curl -sL "${BASE}/galdr-${TAG}-aarch64.tar.gz" | sha256sum | cut -d' ' -f1)
|
||||||
|
sed -i "s/sha256sums_x86_64=('.*')/sha256sums_x86_64=('${SUM_X86}')/" aur/PKGBUILD
|
||||||
|
sed -i "s/sha256sums_aarch64=('.*')/sha256sums_aarch64=('${SUM_AARCH}')/" aur/PKGBUILD
|
||||||
|
|
||||||
|
# Best-effort mirror to the instance Arch registry via a write:package
|
||||||
|
# REGISTRY_TOKEN secret; skipped + non-fatal when unset.
|
||||||
|
- name: Publish to the Arch package registry
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||||
|
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd aur
|
||||||
|
for carch in aarch64 x86_64; do
|
||||||
|
pkgfile=$(CARCH="$carch" makepkg --packagelist | tail -1)
|
||||||
|
CARCH="$carch" makepkg -f --nodeps --noconfirm --skipinteg
|
||||||
|
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
|
||||||
|
--upload-file "$pkgfile" \
|
||||||
|
"${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/arch/uhhm"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Push to AUR
|
||||||
|
env:
|
||||||
|
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
install -dm700 ~/.ssh
|
||||||
|
echo "$AUR_SSH_KEY" > ~/.ssh/aur
|
||||||
|
chmod 600 ~/.ssh/aur
|
||||||
|
echo "Host aur.archlinux.org" >> ~/.ssh/config
|
||||||
|
echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config
|
||||||
|
echo " User aur" >> ~/.ssh/config
|
||||||
|
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
rm -rf /tmp/aur-galdr
|
||||||
|
git clone ssh://aur@aur.archlinux.org/galdr.git /tmp/aur-galdr
|
||||||
|
cp aur/PKGBUILD /tmp/aur-galdr/
|
||||||
|
cd /tmp/aur-galdr
|
||||||
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
|
git config user.name "Bendik Aagaard Lynghaug"
|
||||||
|
git config user.email "bendik.lynghaug@gmail.com"
|
||||||
|
git add PKGBUILD .SRCINFO
|
||||||
|
git commit -m "Update to ${{ gitea.ref_name }}"
|
||||||
|
git push origin master
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+1358
File diff suppressed because it is too large
Load Diff
+19
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "galdr"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A terminal grid synthesizer: signal flows spatially through single-glyph unit generators, voiced by MIDI, rendered low-latency to PipeWire"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
pipewire = "0.10"
|
||||||
|
midir = "0.10"
|
||||||
|
rustfft = "6.2"
|
||||||
|
arc-swap = "1.7"
|
||||||
|
rtrb = "0.3"
|
||||||
|
ratatui = "0.29"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Bendik Aagaard Lynghaug
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# galdr
|
||||||
|
|
||||||
|
A terminal **grid synthesizer**. Signal flows downward through a field of
|
||||||
|
single-glyph unit generators; every incoming MIDI note voices the whole grid;
|
||||||
|
the mix is rendered low-latency to PipeWire. It is orca's voice — the same
|
||||||
|
esoteric, spatial approach to programming, but for making sound instead of
|
||||||
|
sending events.
|
||||||
|
|
||||||
|
```
|
||||||
|
orca-rs ──MIDI/OSC/UDP──▶ galdr ──PipeWire──▶ speakers
|
||||||
|
compose the pattern voice it (spectrust -p visualizes it)
|
||||||
|
```
|
||||||
|
|
||||||
|
## The grid
|
||||||
|
|
||||||
|
Signal flows **down**: each cell takes the sample from the cell above as its
|
||||||
|
input. The left and right neighbour glyphs are parameters — a digit `0`–`9` is
|
||||||
|
a constant, a letter `a`–`z` reads MIDI CC (`a` = CC 0).
|
||||||
|
|
||||||
|
| glyph | unit generator |
|
||||||
|
|---|---|
|
||||||
|
| `~` | sine (ratio ← left) |
|
||||||
|
| `^` | saw (ratio ← left) |
|
||||||
|
| `_` | pulse (width ← left) |
|
||||||
|
| `v` | triangle |
|
||||||
|
| `*` | noise |
|
||||||
|
| `f` | lowpass (cutoff ← left) |
|
||||||
|
| `e` | envelope, attack/release ← left/right, gated by the note |
|
||||||
|
| `s` | shaper / drive ← left |
|
||||||
|
| `!` | output to master |
|
||||||
|
|
||||||
|
Each `!` sums into the master, so parallel columns stack timbres or voices.
|
||||||
|
A note plays every column at once.
|
||||||
|
|
||||||
|
Example — a filtered saw with an envelope:
|
||||||
|
|
||||||
|
```
|
||||||
|
^ saw at the note pitch
|
||||||
|
f lowpass, cutoff from the digit to its left...
|
||||||
|
e ...enveloped by the note gate
|
||||||
|
! ...to master
|
||||||
|
```
|
||||||
|
|
||||||
|
Editing the grid recompiles and hot-swaps the running program without dropping
|
||||||
|
audio.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
galdr [--latency <frames>] [--poly <n>] [--rate <hz>] [--midi-name <s>]
|
||||||
|
```
|
||||||
|
|
||||||
|
galdr creates a virtual MIDI input port named **galdr**; connect orca or any
|
||||||
|
sequencer/DAW to it. `--midi-name` connects to an existing port instead.
|
||||||
|
Cursor keys move; type a glyph to place it; Backspace clears; Esc quits.
|
||||||
|
|
||||||
|
Runtime deps: PipeWire and ALSA (MIDI). Built in Rust; reuses the native
|
||||||
|
PipeWire path from spectrust.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
|
||||||
|
pkgname=galdr
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Terminal grid synthesizer: signal flows through single-glyph unit generators, MIDI in, low-latency PipeWire out"
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
|
options=('!strip')
|
||||||
|
url="https://project.uhhm.no/bl/galdr"
|
||||||
|
license=('MIT')
|
||||||
|
depends=('libpipewire' 'alsa-lib')
|
||||||
|
provides=('galdr')
|
||||||
|
conflicts=('galdr-git' 'galdr-bin')
|
||||||
|
source_x86_64=("galdr-v${pkgver}-x86_64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-x86_64.tar.gz")
|
||||||
|
source_aarch64=("galdr-v${pkgver}-aarch64.tar.gz::https://project.uhhm.no/bl/galdr/releases/download/v${pkgver}/galdr-v${pkgver}-aarch64.tar.gz")
|
||||||
|
sha256sums_x86_64=('SKIP')
|
||||||
|
sha256sums_aarch64=('SKIP')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -Dm755 galdr "$pkgdir/usr/bin/galdr"
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
publish = false
|
||||||
|
allow-branch = ["main"]
|
||||||
|
|
||||||
|
[[pre-release-replacements]]
|
||||||
|
file = "aur/PKGBUILD"
|
||||||
|
search = "pkgver=.*"
|
||||||
|
replace = "pkgver={{version}}"
|
||||||
|
|
||||||
|
[[pre-release-replacements]]
|
||||||
|
file = "aur/PKGBUILD"
|
||||||
|
search = "pkgrel=.*"
|
||||||
|
replace = "pkgrel=1"
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
//! Low-latency PipeWire playback. The RT process callback drains input events,
|
||||||
|
//! renders the voice mixer, fills the output buffer, and taps the master into a
|
||||||
|
//! scope ring for the TUI. No allocation or locks on this path.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use pipewire as pw;
|
||||||
|
use pw::spa;
|
||||||
|
use pw::{properties::properties, spa::pod::Pod};
|
||||||
|
use rtrb::{Consumer, Producer};
|
||||||
|
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
||||||
|
|
||||||
|
use crate::dsp::Program;
|
||||||
|
use crate::engine::{Engine, Event};
|
||||||
|
|
||||||
|
pub type Shared = Arc<ArcSwap<Program>>;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn spawn(
|
||||||
|
prog: Shared,
|
||||||
|
events: Consumer<Event>,
|
||||||
|
scope: Producer<f32>,
|
||||||
|
voices: Arc<AtomicU32>,
|
||||||
|
sr: u32,
|
||||||
|
channels: u8,
|
||||||
|
latency: u32,
|
||||||
|
poly: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("galdr-audio".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(e) =
|
||||||
|
run(prog, events, scope, voices, sr, channels, latency, poly, ready_tx.clone())
|
||||||
|
{
|
||||||
|
let _ = ready_tx.send(Err(e));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("spawn audio thread: {e}"))?;
|
||||||
|
ready_rx.recv().map_err(|_| "audio thread exited before start".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn run(
|
||||||
|
prog: Shared,
|
||||||
|
mut events: Consumer<Event>,
|
||||||
|
mut scope: Producer<f32>,
|
||||||
|
voices: Arc<AtomicU32>,
|
||||||
|
sr: u32,
|
||||||
|
channels: u8,
|
||||||
|
latency: u32,
|
||||||
|
poly: usize,
|
||||||
|
ready: Sender<Result<(), String>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
pw::init();
|
||||||
|
let mainloop = pw::main_loop::MainLoopRc::new(None).map_err(|e| format!("mainloop: {e}"))?;
|
||||||
|
let context = pw::context::ContextRc::new(&mainloop, None).map_err(|e| format!("context: {e}"))?;
|
||||||
|
let core = context.connect_rc(None).map_err(|e| format!("connect PipeWire: {e}"))?;
|
||||||
|
|
||||||
|
let props = properties! {
|
||||||
|
*pw::keys::MEDIA_TYPE => "Audio",
|
||||||
|
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||||
|
*pw::keys::MEDIA_ROLE => "Music",
|
||||||
|
*pw::keys::NODE_NAME => "galdr",
|
||||||
|
*pw::keys::NODE_LATENCY => format!("{latency}/{sr}"),
|
||||||
|
};
|
||||||
|
let stream = pw::stream::StreamBox::new(&core, "galdr", props).map_err(|e| format!("stream: {e}"))?;
|
||||||
|
|
||||||
|
let ch = channels.max(1) as usize;
|
||||||
|
let mut engine = Engine::new(poly, sr as f32);
|
||||||
|
|
||||||
|
let _listener = stream
|
||||||
|
.add_local_listener_with_user_data(())
|
||||||
|
.process(move |stream, _| {
|
||||||
|
let Some(mut buffer) = stream.dequeue_buffer() else { return };
|
||||||
|
let datas = buffer.datas_mut();
|
||||||
|
let Some(data) = datas.first_mut() else { return };
|
||||||
|
let stride = ch * 4;
|
||||||
|
let program = prog.load();
|
||||||
|
|
||||||
|
// Drain all pending input events once per buffer.
|
||||||
|
while let Ok(ev) = events.pop() {
|
||||||
|
engine.apply(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut n_frames = 0usize;
|
||||||
|
if let Some(slice) = data.data() {
|
||||||
|
n_frames = slice.len() / stride;
|
||||||
|
for f in 0..n_frames {
|
||||||
|
let smp = engine.render(&program);
|
||||||
|
let _ = scope.push(smp);
|
||||||
|
let bytes = smp.to_le_bytes();
|
||||||
|
for c in 0..ch {
|
||||||
|
let o = f * stride + c * 4;
|
||||||
|
slice[o..o + 4].copy_from_slice(&bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let chunk = data.chunk_mut();
|
||||||
|
*chunk.offset_mut() = 0;
|
||||||
|
*chunk.stride_mut() = stride as _;
|
||||||
|
*chunk.size_mut() = (stride * n_frames) as _;
|
||||||
|
voices.store(engine.active_voices() as u32, Ordering::Relaxed);
|
||||||
|
})
|
||||||
|
.register()
|
||||||
|
.map_err(|e| format!("listener: {e}"))?;
|
||||||
|
|
||||||
|
let mut audio_info = AudioInfoRaw::new();
|
||||||
|
audio_info.set_format(AudioFormat::F32LE);
|
||||||
|
audio_info.set_rate(sr);
|
||||||
|
audio_info.set_channels(channels as u32);
|
||||||
|
let obj = spa::pod::Object {
|
||||||
|
type_: spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||||
|
id: spa::param::ParamType::EnumFormat.as_raw(),
|
||||||
|
properties: audio_info.into(),
|
||||||
|
};
|
||||||
|
let values: Vec<u8> = spa::pod::serialize::PodSerializer::serialize(
|
||||||
|
std::io::Cursor::new(Vec::new()),
|
||||||
|
&spa::pod::Value::Object(obj),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("pod: {e}"))?
|
||||||
|
.0
|
||||||
|
.into_inner();
|
||||||
|
let mut params = [Pod::from_bytes(&values).ok_or("bad pod")?];
|
||||||
|
|
||||||
|
stream
|
||||||
|
.connect(
|
||||||
|
spa::utils::Direction::Output,
|
||||||
|
None,
|
||||||
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
|
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||||
|
| pw::stream::StreamFlags::RT_PROCESS,
|
||||||
|
&mut params,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("connect stream: {e}"))?;
|
||||||
|
|
||||||
|
let _ = ready.send(Ok(()));
|
||||||
|
mainloop.run();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
//! Grid -> compiled signal program, and the per-sample voice evaluator.
|
||||||
|
//!
|
||||||
|
//! The grid is the editor surface only. On every edit it compiles to a flat,
|
||||||
|
//! row-ordered list of nodes (topological by construction: signal flows
|
||||||
|
//! downward, so a cell's input - the cell above - is always evaluated first).
|
||||||
|
//! The audio thread never sees the grid, only the compiled `Program`.
|
||||||
|
|
||||||
|
use crate::grid::{Grid, H, SLOTS, W};
|
||||||
|
|
||||||
|
pub const MAX_STATE: usize = 4 * SLOTS;
|
||||||
|
pub const TAU: f32 = std::f32::consts::TAU;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum Op {
|
||||||
|
Sine,
|
||||||
|
Saw,
|
||||||
|
Pulse,
|
||||||
|
Tri,
|
||||||
|
Noise,
|
||||||
|
Lpf,
|
||||||
|
Env,
|
||||||
|
Shape,
|
||||||
|
Out,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parameter read from a horizontal neighbour glyph.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum Param {
|
||||||
|
Const(f32),
|
||||||
|
Reg(usize),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Param {
|
||||||
|
#[inline]
|
||||||
|
fn get(self, regs: &[f32]) -> Option<f32> {
|
||||||
|
match self {
|
||||||
|
Param::Const(c) => Some(c),
|
||||||
|
Param::Reg(i) => Some(regs[i]),
|
||||||
|
Param::None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct Node {
|
||||||
|
pub op: Op,
|
||||||
|
pub out: usize,
|
||||||
|
pub input: Option<usize>,
|
||||||
|
pub left: Param,
|
||||||
|
pub right: Param,
|
||||||
|
pub st: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Program {
|
||||||
|
pub nodes: Vec<Node>,
|
||||||
|
pub has_env: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op_of(c: char) -> Option<(Op, usize)> {
|
||||||
|
// (operator, number of persistent state slots it needs per voice)
|
||||||
|
Some(match c {
|
||||||
|
'~' => (Op::Sine, 1),
|
||||||
|
'^' => (Op::Saw, 1),
|
||||||
|
'_' => (Op::Pulse, 1),
|
||||||
|
'v' => (Op::Tri, 1),
|
||||||
|
'*' => (Op::Noise, 0),
|
||||||
|
'f' => (Op::Lpf, 2),
|
||||||
|
'e' => (Op::Env, 2),
|
||||||
|
's' => (Op::Shape, 0),
|
||||||
|
'!' => (Op::Out, 0),
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param_of(c: char) -> Param {
|
||||||
|
match c {
|
||||||
|
'0'..='9' => Param::Const((c as u8 - b'0') as f32),
|
||||||
|
'a'..='z' => Param::Reg((c as u8 - b'a') as usize),
|
||||||
|
_ => Param::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn occupied(c: char) -> bool {
|
||||||
|
!matches!(c, ' ' | '.' | '\0')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compile(grid: &Grid) -> Program {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
let mut st = 0usize;
|
||||||
|
let mut has_env = false;
|
||||||
|
for row in 0..H {
|
||||||
|
for col in 0..W {
|
||||||
|
let Some((op, ssize)) = op_of(grid.get(col, row)) else { continue };
|
||||||
|
if matches!(op, Op::Env) {
|
||||||
|
has_env = true;
|
||||||
|
}
|
||||||
|
let input = if row > 0 && occupied(grid.get(col, row - 1)) {
|
||||||
|
Some((row - 1) * W + col)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let left = if col > 0 { param_of(grid.get(col - 1, row)) } else { Param::None };
|
||||||
|
let right = if col + 1 < W { param_of(grid.get(col + 1, row)) } else { Param::None };
|
||||||
|
let this_st = st;
|
||||||
|
st = (st + ssize).min(MAX_STATE - 4);
|
||||||
|
nodes.push(Node { op, out: row * W + col, input, left, right, st: this_st });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Program { nodes, has_env }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-voice mutable state consumed by the evaluator.
|
||||||
|
pub struct Voice {
|
||||||
|
pub active: bool,
|
||||||
|
pub note: u8,
|
||||||
|
pub freq: f32,
|
||||||
|
pub gate: f32,
|
||||||
|
pub vel: f32,
|
||||||
|
pub env_level: f32,
|
||||||
|
pub age: u64,
|
||||||
|
pub rng: u32,
|
||||||
|
pub state: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Voice {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Voice {
|
||||||
|
active: false,
|
||||||
|
note: 0,
|
||||||
|
freq: 0.0,
|
||||||
|
gate: 0.0,
|
||||||
|
vel: 0.0,
|
||||||
|
env_level: 0.0,
|
||||||
|
age: 0,
|
||||||
|
rng: 0x1234_5678,
|
||||||
|
state: vec![0.0; MAX_STATE],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn cutoff_hz(v: f32) -> f32 {
|
||||||
|
// 0..=9 or a 0..1 CC value -> ~40 Hz .. ~16 kHz, exponential.
|
||||||
|
let n = if v > 1.0 { v / 9.0 } else { v };
|
||||||
|
40.0 * 400f32.powf(n.clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate one output sample for one voice. Returns the summed `!` outputs.
|
||||||
|
#[inline]
|
||||||
|
pub fn eval_voice(v: &mut Voice, p: &Program, regs: &[f32], sr: f32) -> f32 {
|
||||||
|
let mut s = [0f32; SLOTS];
|
||||||
|
let mut out = 0.0f32;
|
||||||
|
let mut env_max = 0.0f32;
|
||||||
|
for n in &p.nodes {
|
||||||
|
let inp = n.input.map(|i| s[i]).unwrap_or(0.0);
|
||||||
|
let l = n.left.get(regs);
|
||||||
|
let r = n.right.get(regs);
|
||||||
|
let y = match n.op {
|
||||||
|
Op::Sine => {
|
||||||
|
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||||
|
let ph = &mut v.state[n.st];
|
||||||
|
let y = (*ph * TAU).sin();
|
||||||
|
*ph += v.freq * ratio / sr;
|
||||||
|
if *ph >= 1.0 {
|
||||||
|
*ph -= 1.0;
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
Op::Saw => {
|
||||||
|
let ratio = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||||
|
let ph = &mut v.state[n.st];
|
||||||
|
let y = 2.0 * *ph - 1.0;
|
||||||
|
*ph += v.freq * ratio / sr;
|
||||||
|
if *ph >= 1.0 {
|
||||||
|
*ph -= 1.0;
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
Op::Pulse => {
|
||||||
|
let width = l.map(|x| (x / 9.0).clamp(0.05, 0.95)).unwrap_or(0.5);
|
||||||
|
let ph = &mut v.state[n.st];
|
||||||
|
let y = if *ph < width { 1.0 } else { -1.0 };
|
||||||
|
*ph += v.freq / sr;
|
||||||
|
if *ph >= 1.0 {
|
||||||
|
*ph -= 1.0;
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
Op::Tri => {
|
||||||
|
let ph = &mut v.state[n.st];
|
||||||
|
let y = 4.0 * (*ph - 0.5).abs() - 1.0;
|
||||||
|
*ph += v.freq / sr;
|
||||||
|
if *ph >= 1.0 {
|
||||||
|
*ph -= 1.0;
|
||||||
|
}
|
||||||
|
y
|
||||||
|
}
|
||||||
|
Op::Noise => {
|
||||||
|
// xorshift32
|
||||||
|
v.rng ^= v.rng << 13;
|
||||||
|
v.rng ^= v.rng >> 17;
|
||||||
|
v.rng ^= v.rng << 5;
|
||||||
|
(v.rng as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||||
|
}
|
||||||
|
Op::Lpf => {
|
||||||
|
let fc = cutoff_hz(l.unwrap_or(9.0));
|
||||||
|
let a = (1.0 - (-TAU * fc / sr).exp()).clamp(0.0, 1.0);
|
||||||
|
let z = &mut v.state[n.st];
|
||||||
|
*z += a * (inp - *z);
|
||||||
|
let _res = r; // reserved for resonance later
|
||||||
|
*z
|
||||||
|
}
|
||||||
|
Op::Env => {
|
||||||
|
// Attack/Release, sustain = 1. l = attack, r = release (0..9).
|
||||||
|
let atk = l.map(|x| 0.002 + x * 0.05).unwrap_or(0.01);
|
||||||
|
let rel = r.map(|x| 0.01 + x * 0.15).unwrap_or(0.15);
|
||||||
|
let level = &mut v.state[n.st];
|
||||||
|
let target = if v.gate > 0.5 { 1.0 } else { 0.0 };
|
||||||
|
let rate = if v.gate > 0.5 { atk } else { rel };
|
||||||
|
let step = (1.0 / (rate * sr)).min(1.0);
|
||||||
|
*level += (target - *level) * step;
|
||||||
|
if *level > env_max {
|
||||||
|
env_max = *level;
|
||||||
|
}
|
||||||
|
inp * *level * v.vel
|
||||||
|
}
|
||||||
|
Op::Shape => {
|
||||||
|
let drive = l.filter(|&x| x > 0.0).unwrap_or(1.0);
|
||||||
|
(inp * drive).tanh()
|
||||||
|
}
|
||||||
|
Op::Out => {
|
||||||
|
out += inp;
|
||||||
|
inp
|
||||||
|
}
|
||||||
|
};
|
||||||
|
s[n.out] = y;
|
||||||
|
}
|
||||||
|
// With no envelope, gate directly gates the voice so notes don't sustain
|
||||||
|
// forever.
|
||||||
|
if !p.has_env {
|
||||||
|
out *= v.gate * v.vel;
|
||||||
|
env_max = v.gate;
|
||||||
|
}
|
||||||
|
v.env_level = env_max;
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Voice allocation and the polyphonic mixer. Owned by the audio thread.
|
||||||
|
|
||||||
|
use crate::dsp::{eval_voice, Program, Voice};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum Event {
|
||||||
|
NoteOn { note: u8, vel: u8 },
|
||||||
|
NoteOff { note: u8 },
|
||||||
|
Cc { idx: u8, val: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Engine {
|
||||||
|
voices: Vec<Voice>,
|
||||||
|
regs: Vec<f32>,
|
||||||
|
sr: f32,
|
||||||
|
clock: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn new(poly: usize, sr: f32) -> Self {
|
||||||
|
Engine {
|
||||||
|
voices: (0..poly).map(|_| Voice::new()).collect(),
|
||||||
|
regs: vec![0.0; 128],
|
||||||
|
sr,
|
||||||
|
clock: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_voices(&self) -> usize {
|
||||||
|
self.voices.iter().filter(|v| v.active).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&mut self, ev: Event) {
|
||||||
|
match ev {
|
||||||
|
Event::NoteOn { note, vel } => {
|
||||||
|
self.clock += 1;
|
||||||
|
// Prefer a free voice, else steal the oldest.
|
||||||
|
let idx = self
|
||||||
|
.voices
|
||||||
|
.iter()
|
||||||
|
.position(|v| !v.active)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let mut best = 0;
|
||||||
|
for i in 1..self.voices.len() {
|
||||||
|
if self.voices[i].age < self.voices[best].age {
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
});
|
||||||
|
let v = &mut self.voices[idx];
|
||||||
|
v.active = true;
|
||||||
|
v.note = note;
|
||||||
|
v.freq = 440.0 * 2f32.powf((note as f32 - 69.0) / 12.0);
|
||||||
|
v.gate = 1.0;
|
||||||
|
v.vel = vel as f32 / 127.0;
|
||||||
|
v.age = self.clock;
|
||||||
|
// fresh phases/env so a stolen voice doesn't click from old state
|
||||||
|
for x in v.state.iter_mut() {
|
||||||
|
*x = 0.0;
|
||||||
|
}
|
||||||
|
v.env_level = 0.0;
|
||||||
|
}
|
||||||
|
Event::NoteOff { note } => {
|
||||||
|
for v in self.voices.iter_mut() {
|
||||||
|
if v.active && v.note == note {
|
||||||
|
v.gate = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Cc { idx, val } => {
|
||||||
|
self.regs[idx as usize] = val as f32 / 127.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn render(&mut self, prog: &Program) -> f32 {
|
||||||
|
let mut mix = 0.0f32;
|
||||||
|
for v in self.voices.iter_mut() {
|
||||||
|
if !v.active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mix += eval_voice(v, prog, &self.regs, self.sr);
|
||||||
|
// Reap voices whose gate is closed and envelope has decayed.
|
||||||
|
if v.gate < 0.5 && v.env_level < 1e-4 {
|
||||||
|
v.active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gentle headroom limiter.
|
||||||
|
(mix * 0.3).tanh()
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
//! The fixed-size character grid that is the editor surface.
|
||||||
|
|
||||||
|
pub const W: usize = 24;
|
||||||
|
pub const H: usize = 16;
|
||||||
|
pub const SLOTS: usize = W * H;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Grid {
|
||||||
|
cells: Vec<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grid {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Grid { cells: vec!['.'; SLOTS] }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn get(&self, col: usize, row: usize) -> char {
|
||||||
|
if col < W && row < H {
|
||||||
|
self.cells[row * W + col]
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&mut self, col: usize, row: usize, c: char) {
|
||||||
|
if col < W && row < H {
|
||||||
|
self.cells[row * W + col] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
//! galdr - a terminal grid synthesizer.
|
||||||
|
//!
|
||||||
|
//! Signal flows downward through single-glyph unit generators; each MIDI note
|
||||||
|
//! voices the whole grid; the mix is rendered low-latency to PipeWire. Pairs
|
||||||
|
//! with orca (MIDI/OSC/UDP out) and spectrust (visualize the output).
|
||||||
|
|
||||||
|
mod audio;
|
||||||
|
mod dsp;
|
||||||
|
mod engine;
|
||||||
|
mod grid;
|
||||||
|
mod midi;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
use std::sync::atomic::AtomicU32;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
|
use crate::dsp::compile;
|
||||||
|
use crate::grid::Grid;
|
||||||
|
|
||||||
|
const HELP: &str = "\
|
||||||
|
galdr - terminal grid synthesizer
|
||||||
|
|
||||||
|
Usage: galdr [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--latency <frames> PipeWire quantum; lower = lower latency (default: 256)
|
||||||
|
--poly <n> Number of voices (default: 8)
|
||||||
|
--rate <hz> Sample rate (default: 48000)
|
||||||
|
--midi-name <s> Connect to the input port matching <s> instead of
|
||||||
|
creating the virtual 'galdr' port
|
||||||
|
-h, --help Print this message
|
||||||
|
|
||||||
|
Grid glyphs (signal flows down; left/right neighbours are params 0-9 or CC a-z):
|
||||||
|
~ sine ^ saw _ pulse v tri * noise
|
||||||
|
f lowpass(cutoff<-left) e envelope(atk<-left,rel<-right) s shaper(drive<-left)
|
||||||
|
! output to master
|
||||||
|
";
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let mut latency: u32 = 256;
|
||||||
|
let mut poly: usize = 8;
|
||||||
|
let mut rate: u32 = 48000;
|
||||||
|
let channels: u8 = 2;
|
||||||
|
let mut midi_name: Option<String> = None;
|
||||||
|
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(a) = args.next() {
|
||||||
|
match a.as_str() {
|
||||||
|
"--latency" => latency = args.next().and_then(|v| v.parse().ok()).unwrap_or(latency),
|
||||||
|
"--poly" => poly = args.next().and_then(|v| v.parse().ok()).unwrap_or(poly),
|
||||||
|
"--rate" => rate = args.next().and_then(|v| v.parse().ok()).unwrap_or(rate),
|
||||||
|
"--midi-name" => midi_name = args.next(),
|
||||||
|
"-h" | "--help" => {
|
||||||
|
print!("{HELP}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("unknown option: {other}\n\n{HELP}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A default patch so the first run makes sound: a plain sine->env voice and
|
||||||
|
// a filtered saw voice.
|
||||||
|
let mut grid = Grid::new();
|
||||||
|
grid.set(0, 0, '~');
|
||||||
|
grid.set(0, 1, 'e');
|
||||||
|
grid.set(0, 2, '!');
|
||||||
|
grid.set(2, 0, '^');
|
||||||
|
grid.set(1, 1, '5');
|
||||||
|
grid.set(2, 1, 'f');
|
||||||
|
grid.set(2, 2, 'e');
|
||||||
|
grid.set(2, 3, '!');
|
||||||
|
|
||||||
|
let prog = Arc::new(ArcSwap::from_pointee(compile(&grid)));
|
||||||
|
let voices = Arc::new(AtomicU32::new(0));
|
||||||
|
|
||||||
|
let (prod_ev, cons_ev) = rtrb::RingBuffer::new(1024);
|
||||||
|
let (prod_sc, cons_sc) = rtrb::RingBuffer::new(16384);
|
||||||
|
|
||||||
|
audio::spawn(prog.clone(), cons_ev, prod_sc, voices.clone(), rate, channels, latency, poly)
|
||||||
|
.map_err(|e| anyhow::anyhow!("audio: {e}"))?;
|
||||||
|
|
||||||
|
// Keep the MIDI connection alive for the program's lifetime.
|
||||||
|
let _midi = match midi::connect(prod_ev, midi_name) {
|
||||||
|
Ok(conn) => Some(conn),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("warning: MIDI unavailable ({e}); running without note input");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tui::run(prog, cons_sc, voices, grid, latency)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
//! MIDI input. galdr owns a virtual input port named "galdr" (symmetric with
|
||||||
|
//! orca's virtual output), so orca or any client connects to it in the graph.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use midir::os::unix::VirtualInput;
|
||||||
|
use midir::{MidiInput, MidiInputConnection};
|
||||||
|
use rtrb::Producer;
|
||||||
|
|
||||||
|
use crate::engine::Event;
|
||||||
|
|
||||||
|
pub fn connect(prod: Producer<Event>, port_name: Option<String>) -> Result<MidiInputConnection<Producer<Event>>> {
|
||||||
|
let mut input = MidiInput::new("galdr").map_err(|e| anyhow!("MIDI init: {e}"))?;
|
||||||
|
input.ignore(midir::Ignore::None);
|
||||||
|
|
||||||
|
let cb = move |_stamp: u64, msg: &[u8], prod: &mut Producer<Event>| {
|
||||||
|
if msg.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let status = msg[0] & 0xF0;
|
||||||
|
let ev = match status {
|
||||||
|
0x90 if msg[2] > 0 => Event::NoteOn { note: msg[1], vel: msg[2] },
|
||||||
|
0x80 => Event::NoteOff { note: msg[1] },
|
||||||
|
0x90 => Event::NoteOff { note: msg[1] }, // note-on velocity 0
|
||||||
|
0xB0 => Event::Cc { idx: msg[1], val: msg[2] },
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = prod.push(ev);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(name) = port_name {
|
||||||
|
let ports = input.ports();
|
||||||
|
let port = ports
|
||||||
|
.iter()
|
||||||
|
.find(|p| input.port_name(p).map(|n| n.contains(&name)).unwrap_or(false))
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("no MIDI input port matching {name:?}"))?;
|
||||||
|
input.connect(&port, "galdr-in", cb, prod).map_err(|e| anyhow!("MIDI connect: {e}"))
|
||||||
|
} else {
|
||||||
|
input.create_virtual("galdr", cb, prod).map_err(|e| anyhow!("create virtual MIDI port: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
//! Terminal editor: the grid on the left, a live spectrum of the master on the
|
||||||
|
//! right, a status line below. Editing recompiles and hot-swaps the program.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use ratatui::crossterm::event::{self, Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
|
use ratatui::prelude::*;
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
|
use rtrb::Consumer;
|
||||||
|
use rustfft::{num_complex::Complex, FftPlanner};
|
||||||
|
|
||||||
|
use crate::dsp::{compile, Program};
|
||||||
|
use crate::grid::{Grid, H, W};
|
||||||
|
|
||||||
|
const FFT: usize = 1024;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
prog: Arc<ArcSwap<Program>>,
|
||||||
|
mut scope: Consumer<f32>,
|
||||||
|
voices: Arc<AtomicU32>,
|
||||||
|
mut grid: Grid,
|
||||||
|
latency: u32,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let mut terminal = ratatui::init();
|
||||||
|
|
||||||
|
let mut samples = vec![0.0f32; FFT];
|
||||||
|
let mut planner = FftPlanner::new();
|
||||||
|
let fft = planner.plan_fft_forward(FFT);
|
||||||
|
let hann: Vec<f32> = (0..FFT)
|
||||||
|
.map(|i| 0.5 * (1.0 - (std::f32::consts::TAU * i as f32 / FFT as f32).cos()))
|
||||||
|
.collect();
|
||||||
|
let (mut cx, mut cy) = (0usize, 0usize);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Pull whatever the audio thread produced into the rolling window.
|
||||||
|
while let Ok(s) = scope.pop() {
|
||||||
|
samples.rotate_left(1);
|
||||||
|
samples[FFT - 1] = s;
|
||||||
|
}
|
||||||
|
let mut buf: Vec<Complex<f32>> =
|
||||||
|
samples.iter().zip(&hann).map(|(s, w)| Complex { re: s * w, im: 0.0 }).collect();
|
||||||
|
fft.process(&mut buf);
|
||||||
|
let mags: Vec<f32> = buf[..FFT / 2].iter().map(|c| c.norm()).collect();
|
||||||
|
|
||||||
|
let nv = voices.load(Ordering::Relaxed);
|
||||||
|
terminal.draw(|f| ui(f, &grid, cx, cy, &mags, nv, latency))?;
|
||||||
|
|
||||||
|
if event::poll(std::time::Duration::from_millis(33))? {
|
||||||
|
if let CEvent::Key(k) = event::read()? {
|
||||||
|
if k.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if k.code == KeyCode::Esc
|
||||||
|
|| (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut changed = false;
|
||||||
|
match k.code {
|
||||||
|
KeyCode::Left => cx = cx.saturating_sub(1),
|
||||||
|
KeyCode::Right => cx = (cx + 1).min(W - 1),
|
||||||
|
KeyCode::Up => cy = cy.saturating_sub(1),
|
||||||
|
KeyCode::Down => cy = (cy + 1).min(H - 1),
|
||||||
|
KeyCode::Backspace | KeyCode::Delete => {
|
||||||
|
grid.set(cx, cy, '.');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
grid.set(cx, cy, if c == ' ' { '.' } else { c });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
prog.store(Arc::new(compile(&grid)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratatui::restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(f: &mut Frame, grid: &Grid, cx: usize, cy: usize, mags: &[f32], voices: u32, latency: u32) {
|
||||||
|
let root = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
|
||||||
|
let cols = Layout::horizontal([Constraint::Length((W + 3) as u16), Constraint::Min(10)]).split(root[0]);
|
||||||
|
|
||||||
|
// Grid pane.
|
||||||
|
let mut lines = Vec::with_capacity(H);
|
||||||
|
for row in 0..H {
|
||||||
|
let mut spans = Vec::with_capacity(W);
|
||||||
|
for col in 0..W {
|
||||||
|
let ch = grid.get(col, row);
|
||||||
|
let mut st = match ch {
|
||||||
|
'~' | '^' | '_' | 'v' | '*' => Style::default().fg(Color::Cyan),
|
||||||
|
'f' | 's' => Style::default().fg(Color::Yellow),
|
||||||
|
'e' => Style::default().fg(Color::Green),
|
||||||
|
'!' => Style::default().fg(Color::Magenta),
|
||||||
|
'.' => Style::default().fg(Color::DarkGray),
|
||||||
|
_ => Style::default().fg(Color::White),
|
||||||
|
};
|
||||||
|
if col == cx && row == cy {
|
||||||
|
st = st.add_modifier(Modifier::REVERSED);
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(ch.to_string(), st));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" galdr ")),
|
||||||
|
cols[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spectrum pane.
|
||||||
|
let area = cols[1];
|
||||||
|
let inner_w = area.width.saturating_sub(2) as usize;
|
||||||
|
let inner_h = area.height.saturating_sub(2) as usize;
|
||||||
|
let mut srows = vec![String::new(); inner_h];
|
||||||
|
if inner_w > 0 && inner_h > 0 {
|
||||||
|
let maxm = mags.iter().cloned().fold(1e-6f32, f32::max);
|
||||||
|
for x in 0..inner_w {
|
||||||
|
// log-ish bin mapping into the lower spectrum
|
||||||
|
let frac = (x as f32 / inner_w as f32).powf(1.6);
|
||||||
|
let bin = ((frac * mags.len() as f32) as usize).min(mags.len() - 1);
|
||||||
|
let mag = (mags[bin] / maxm).sqrt();
|
||||||
|
let filled = (mag * inner_h as f32) as usize;
|
||||||
|
for y in 0..inner_h {
|
||||||
|
srows[inner_h - 1 - y].push(if y < filled { '█' } else { ' ' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let scope_lines: Vec<Line> = srows.into_iter().map(|s| Line::from(Span::styled(s, Style::default().fg(Color::Green)))).collect();
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(scope_lines).block(Block::default().borders(Borders::ALL).title(" spectrum ")),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status line.
|
||||||
|
let status = format!(
|
||||||
|
" voices {voices} · latency {latency}f · glyphs ~^_v* f e s ! · Esc quit "
|
||||||
|
);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
|
||||||
|
root[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user