From c12183811809021648d380d096ce2d85ac5e9bb6 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Mon, 17 Aug 2026 23:53:31 +0000 Subject: [PATCH] The machine's drum head learns to be rubbed, not only hit: AC Native already had the whole strike side of TrackDrum — Bessel eigenmodes excited at the contact point, resting fingers damping the modes they sit on, five concentric instruments across the head — but the friction was still Mac-only, so a finger dragged across the pad on the machine made either a chain of strikes or nothing at all. That gesture is not a note. There is no note-off to wait for and no duration to schedule; the sound exists exactly as long as a finger keeps moving, and every property of it follows where that finger is. So it lands as one continuous voice mixed after the modal strikes rather than as an entry in the note pool, with the control side re-stating it each frame and the voice slewing to whatever it was last told. The DSP is its own dependency-free unit, src/scratch_voice.c, for the same reason gm_synth.c and fluoddity_voice.c are: audio.c cannot be compiled without ALSA, so anything living inside it can only be checked on the hardware it ships to. Split out, the friction builds and auditions on a laptop. Two low-passed copies of one noise source subtracted from each other make a band whose centre and width follow the material under the finger; a tanh makes the grip bite instead of hiss; and a sine head carrier is frequency-modulated by the friction itself — that last detail is the one worth not losing in a rewrite, because without it this is a filtered noise sweep and sounds like one, and with it the head wobbles because the rubbing is uneven rather than because an LFO said so. Control lives in the piece, matching how MenuBandPercussion splits it on macOS. pieces/notepat.mjs measures the gesture and maps it: the fastest-moving finger owns the rub (one head, one voice — a second sliding finger is more of the same hand, not a second surface), speed moves the head's pitch in OCTAVES rather than hertz so a brisk drag sweeps a couple of octaves instead of crowding the top of the range, resting fingers raise tension and shorten the release exactly as they damp a strike, and the seams between materials have more tooth than the fields either side, so crossing one is louder than sliding along it. The material contours are the same numbers as the strike bands, so rubbing and hitting agree about where the skin stops and the rim starts. The release is driven from the no-motion path as well as on lift — a finger held still on the head has stopped rubbing, and without that the voice would sing forever. tools/scratch-audition.c sits beside pitch-audit.c and surface-audit.c and renders the gestures that would betray a broken port: a drag from the middle to the metal edge, one spot rubbed at rising speed, a step through the five materials, and a finger stopping dead. Measured through the timbre analyzer, both laws hold monotonically — gesture speed 526 → 684 Hz, centre-to-edge 308 → 1475 Hz — no non-finite samples anywhere, and the release take falls to exactly zero, which is the one assertion the harness fails on. Verified as far as a Mac allows: scratch_voice.c compiles clean under -Wall -Wextra, the piece parses, and the audition passes. audio.c and js-bindings.c need Linux (ALSA, quickjs) and are unbuilt here; the wiring in both is small and mechanical, but it is unbuilt, and this ships nothing until `ac-os oven` runs. Also ran the synth core through the project's own gate while in the neighbourhood: tools/pitch-audit.c reports PASS 91 / FAIL 4 / uncertain 33 both before and after the brass work in fd481fdf1, so that change is neutral there. The four are pre-existing and worth their own pass — Drawbar, Percussive and Rock Organ plus Bagpipe all sound a full octave below the note they were asked for, the same class of bug as the web synth's square. --- fedac/native/Makefile | 1 + fedac/native/pieces/notepat.mjs | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ fedac/native/src/audio.c | 42 ++++++++++++++++++++++++++++++++++++++++++ fedac/native/src/audio.h | 29 ++++++++++++++++++++++++++++- fedac/native/src/js-bindings.c | 43 +++++++++++++++++++++++++++++++++++++++++++ fedac/native/src/scratch_voice.c | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ fedac/native/src/scratch_voice.h | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ fedac/native/tools/scratch-audition.c | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 file(s) changed, 637 insertion(s)(+), 1 deletion(s)(-) diff --git a/fedac/native/Makefile b/fedac/native/Makefile --- a/fedac/native/Makefile +++ b/fedac/native/Makefile @@ -96,6 +96,7 @@ $(SRCDIR)/color.c \ $(SRCDIR)/input.c \ $(SRCDIR)/audio.c \ $(SRCDIR)/gm_synth.c \ + $(SRCDIR)/scratch_voice.c \ $(SRCDIR)/usb-midi.c \ $(SRCDIR)/wifi.c \ $(SRCDIR)/tts.c \ diff --git a/fedac/native/pieces/notepat.mjs b/fedac/native/pieces/notepat.mjs --- a/fedac/native/pieces/notepat.mjs +++ b/fedac/native/pieces/notepat.mjs @@ -2058,6 +2058,117 @@ padStrikes.push({ x, y, zone, frame }); if (padStrikes.length > 12) padStrikes.shift(); } +// ── Rubbing the head, as opposed to hitting it ── +// +// The strike path above is a note: it fires, it decays, it is over. Dragging a +// finger across the head is not a note — it is a sound that exists exactly as +// long as the finger keeps moving, and everything about it follows where the +// finger is. So the piece measures the gesture and re-states it every frame, +// and `sound.scratch` holds one continuous voice at whatever it was last told. +// Same split Menu Band uses (MenuBandPercussion.setDrumSkinScratch); the +// per-sample end lives in native's audio.c. +// +// The material contours are the SAME numbers as the strike bands, so rubbing +// and hitting agree about where the skin stops and the rim starts. +let padScratchOn = false; // is a voice currently sounding? +let padScratchSpeed = 0; // smoothed pad-lengths per second + +// Speed moves the head's pitch in OCTAVES, not hertz — equal additions of +// gesture speed give equal musical movement, so a brisk drag sweeps a bit over +// two octaves instead of crowding everything into the top of the range. +function padScratchPitch(speed, octaveSpan = 2.25) { + return Math.pow(2, Math.min(octaveSpan, Math.max(0, speed) * 0.82)); +} + +// 0 = travelling along a contour, 1 = crossing it head-on. Normalized by +// physical travel so gesture speed cannot leak into an orientation measure. +function padScratchCrossing(x, y, dx, dy, aspect) { + const { hw, hh } = padHalfExtent(aspect); + const here = padDepth((x - 0.5) * 2, (y - 0.5) * 2, aspect); + const there = padDepth((x - dx - 0.5) * 2, (y - dy - 0.5) * 2, aspect); + const travel = Math.hypot(dx * (2 / hw), dy * (2 / hh)); + if (travel < 1e-6) return 0; + return Math.min(1, Math.abs(here - there) / travel); +} + +// Map a sliding finger onto the friction voice. `speed` is pad lengths per +// second; `dx, dy` is this frame's travel. +function updatePadScratch(sound, c, anchors, speed, dx, dy, aspect) { + if (!sound?.scratch) return; + const sx = (c.x - 0.5) * 2; + const sy = (c.y - 0.5) * 2; + const radius = padDepth(sx, sy, aspect); + + // Resting fingers press the head tighter and mute it faster, exactly as + // they damp the modes on a strike. + const separations = anchors.map((a) => + Math.min(1, Math.hypot((a.x - c.x) * 1.64, a.y - c.y)), + ); + const proximity = separations.length + ? separations.reduce((t, s) => t + (1 - s), 0) / separations.length + : 0; + const tension = 1 + anchors.length * 0.14 + proximity * 0.30; + + // Seams: the boundaries between materials have more tooth than the fields + // either side, and crossing one is louder than sliding along it. + const seam = [0.30, 0.46, 0.64, 0.88].reduce( + (m, edge) => Math.max(m, Math.exp(-Math.pow((radius - edge) / 0.045, 2))), + 0, + ); + const crossing = padScratchCrossing(c.x, c.y, dx, dy, aspect); + + let level = Math.min(0.14, Math.max(0, speed) * 0.052); + level = Math.min(0.14, level * (1 + crossing * (0.12 + seam * 0.24))); + + const mix = (a, b, t) => a + (b - a) * t; + const toSnare = padSmoothstep(0.23, 0.31, radius); + const toRim = padSmoothstep(0.40, 0.48, radius); + const toHat = padSmoothstep(0.62, 0.70, radius); + const toClick = padSmoothstep(0.88, 0.965, radius); + + // Skin is dry and covered — mostly low-mid friction. Tooth arrives with the + // wires, the rim, and finally the bare metal edge. + let cutoff = mix(175, 430, toSnare); + cutoff = mix(cutoff, 680, toRim); + cutoff = mix(cutoff, 1250, toHat); + cutoff = mix(cutoff, 2050, toClick); + cutoff *= (0.88 + tension * 0.12) * (1 + crossing * seam * 0.20); + + let resonance = mix(mix(mix(mix(48, 90, toSnare), 185, toRim), 360, toHat), 560, toClick) + * tension; + const pathVariation = 1 + 0.055 * Math.sin((sx * 2.7 + sy * 3.9) * Math.PI); + // Direction uses only the unit vector, never the delta magnitude, so + // callback jitter cannot masquerade as acceleration. + const dirMag = Math.max(1e-6, Math.hypot(dx, dy)); + const directionBend = 1 + Math.max(-0.06, Math.min(0.06, + (dx * 0.045 + dy * 0.030) / dirMag)); + resonance *= pathVariation * padScratchPitch(speed) * directionBend + * (1 + crossing * seam * 0.14); + + let roughness = mix(0.30, 0.78, toSnare); + roughness = mix(roughness, 0.48, toRim); + roughness = mix(roughness, 0.70, toHat); + roughness = mix(roughness, 0.38, toClick); + roughness = Math.min(1, roughness + proximity * 0.26 + crossing * seam * 0.24); + + sound.scratch({ + level: level * TRACKDRUM_MIX_GAIN, + cutoff, + resonance, + roughness, + release: Math.max(0.004, 0.010 - anchors.length * 0.0012 - proximity * 0.003), + pan: padClamp((c.x - 0.5) * 1.45, -1, 1), + }); + padScratchOn = true; +} + +function stopPadScratch(sound) { + if (!padScratchOn) return; // idempotent: don't re-cross the JS/C boundary + padScratchOn = false; + padScratchSpeed = 0; + sound?.scratch?.(null); +} + // Poll the pad once a frame and turn new contacts or deliberate slides into // strikes. A resting tracking ID stays quiet; after moving 4.5% of the pad // from its last strike point, it can re-strike after a 70ms-ish cooldown. This @@ -2068,10 +2179,42 @@ const contacts = trackpad.contacts || []; // `generation` only moves when the contact set actually changed, so an // unmoving hand costs one integer compare per frame. if (trackpad.generation === padGeneration && contacts.length === padContacts.size) { + // A hand that has stopped moving is a hand that has stopped rubbing. The + // friction has to be released HERE and not only on lift, or a finger held + // still on the head would keep the voice singing forever. + stopPadScratch(sound); return; } padGeneration = trackpad.generation; const aspect = trackpad.aspect || PAD_FALLBACK_ASPECT; + const nowMs = Date.now(); + + // Friction first: the fastest-moving finger owns the rub. One head, one + // continuous voice — a second finger sliding is more of the same hand, not + // a second surface, and mixing two would just be louder rather than richer. + let rubber = null; + for (let i = 0; i < contacts.length; i++) { + const c = contacts[i]; + const previous = padContacts.get(c.id); + if (!previous) continue; // a new touch is a strike + const dx = c.x - previous.x; + const dy = c.y - previous.y; + const dt = Math.max(0.001, (nowMs - (previous.at ?? nowMs)) / 1000); + const speed = Math.hypot(dx, dy) / dt; + if (speed > 0.02 && (!rubber || speed > rubber.speed)) { + rubber = { c, dx, dy, speed }; + } + } + if (rubber) { + // One-pole smoothing: the pad reports in bursts, and unsmoothed speed + // makes the head's pitch flicker instead of sweep. + padScratchSpeed += 0.35 * (rubber.speed - padScratchSpeed); + const anchors = contacts.filter((o) => o.id !== rubber.c.id); + updatePadScratch(sound, rubber.c, anchors, padScratchSpeed, + rubber.dx, rubber.dy, aspect); + } else { + stopPadScratch(sound); + } for (let i = 0; i < contacts.length; i++) { const c = contacts[i]; @@ -2109,6 +2252,7 @@ const didStrike = !previous || (distance > 0.045 && frame - (previous.strikeFrame ?? -999) >= 5); next.set(c.id, { x: c.x, y: c.y, + at: nowMs, // for the next frame's rub speed strikeX: didStrike ? c.x : (previous?.strikeX ?? c.x), strikeY: didStrike ? c.y : (previous?.strikeY ?? c.y), strikeFrame: didStrike ? frame : (previous?.strikeFrame ?? frame), diff --git a/fedac/native/src/audio.c b/fedac/native/src/audio.c --- a/fedac/native/src/audio.c +++ b/fedac/native/src/audio.c @@ -741,6 +741,40 @@ } pthread_mutex_unlock(&oneshot_lock); } +// ── Drum-skin friction ── +// +// The DSP lives in scratch_voice.c, dependency-free like gm_synth.c and +// fluoddity_voice.c, so it can be built and auditioned without ALSA (see +// tools/scratch-audition.c). This is only the wiring. +static void mix_scratch(ACAudio *audio, double rate, double *mix_l, double *mix_r) { + if (!scratch_voice_active(&audio->scratch)) return; + double l = 0.0, r = 0.0; + scratch_voice_render(&audio->scratch, rate, &l, &r); + *mix_l += l; + *mix_r += r; +} + +void audio_scratch_set(ACAudio *audio, double level, double cutoff, + double resonance, double roughness, double release, + double pan, int synthetic) { + if (!audio) return; + ScratchParams p = { + .target = level, .cutoff = cutoff, .resonance = resonance, + .roughness = roughness, .release = release, .pan = pan, + .synthetic = synthetic, + }; + pthread_mutex_lock(&audio->lock); + scratch_voice_set(&audio->scratch, &p); + pthread_mutex_unlock(&audio->lock); +} + +void audio_scratch_stop(ACAudio *audio) { + if (!audio) return; + pthread_mutex_lock(&audio->lock); + scratch_voice_stop(&audio->scratch); + pthread_mutex_unlock(&audio->lock); +} + // Mix all active one-shot voices into the (mix_l, mix_r) bus. Called // from the audio thread alongside the SampleVoice mixer. static void mix_oneshot_voices(double rate, double *mix_l, double *mix_r) { @@ -1892,6 +1926,10 @@ // points at its own buffer in oneshot_bank[]; up to // ONESHOT_MAX_VOICES concurrent. mix_oneshot_voices(rate, &mix_l, &mix_r); + // Drum-skin friction. After the modal strikes, on the same bus, so + // a rub and a hit land in the same room. + mix_scratch(audio, rate, &mix_l, &mix_r); + // Mix DJ deck audio (lock-free: single consumer = audio thread) // Speed control: advance ring read by `speed` samples per output sample // with linear interpolation for smooth pitch shifting / scratching. @@ -2467,6 +2505,10 @@ } // Build the sine wavetable used by the GM modal/FM voices (idempotent). gm_synth_init(); + + // The friction voice's defaults are not all zero (a zero release time is + // an instant cut, not a release), and calloc cannot know that. + scratch_voice_init(&audio->scratch); // Load piano sample bank from /samples/piano/. Idempotent: safe to // call from both audio_init paths in ac-native.c. Bank is global diff --git a/fedac/native/src/audio.h b/fedac/native/src/audio.h --- a/fedac/native/src/audio.h +++ b/fedac/native/src/audio.h @@ -5,7 +5,8 @@ #include #include #include #include "audio-decode.h" -#include "gm_synth.h" // standalone GM voice state (GMVoice) + render API +#include "gm_synth.h" // standalone GM voice state (GMVoice) + render API +#include "scratch_voice.h" // standalone drum-skin friction voice #define AUDIO_SAMPLE_RATE 192000 #define AUDIO_CHANNELS 2 @@ -437,6 +438,14 @@ // Recording tap: if set, called after each mixed period with final int16 PCM void (*rec_callback)(const int16_t *pcm, int frames, void *userdata); void *rec_userdata; + // ── Drum-skin friction ── + // Striking the pad is a note; RUBBING it is not. There is no note-off to + // wait for and no duration to schedule — the sound exists exactly as long + // as a finger is moving. So it is one continuous voice here rather than an + // entry in the note-based pool, mixed after the modal strikes. + // State and DSP live in scratch_voice.h/.c. + ScratchVoice scratch; + // Diagnostic info (exposed to JS via system.hw) char audio_device[32]; // ALSA device name that opened successfully char audio_status[64]; // human-readable status ("ok", "no card", etc.) @@ -510,6 +519,24 @@ // stochasticism (docs/gm-synthesis/00-stochasticism.md). 0.0 = bit-identical, // 1.0 = max tasteful spread. Default 0.6. Scales every parametric jitter // lever (per-partial amp/decay, pitch detune, FM index, attack, pan). void audio_set_organic(double amt); + +// ── Drum-skin friction ── +// Rub the pad instead of striking it. Call once per control frame while a +// finger is sliding; the voice slews to whatever it was last told, so a gap +// between calls sustains rather than stutters. `level` 0 releases it, and +// audio_scratch_stop is the same thing said plainly. +// +// level 0..~0.22 amplitude the friction is asking for +// cutoff friction band centre in Hz (material: skin dull, rim bright) +// resonance head carrier in Hz — this is what gesture speed moves +// roughness 0..1 grip nonlinearity +// release fall time in seconds once the finger stops +// pan -1..1 +// synthetic 1 = the broader ring-modulated electro surface +void audio_scratch_set(ACAudio *audio, double level, double cutoff, + double resonance, double roughness, double release, + double pan, int synthetic); +void audio_scratch_stop(ACAudio *audio); void audio_set_output_history_paused(ACAudio *audio, int paused); // Microphone — hot-mic mode (device stays open, recording toggles buffering) diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -2888,6 +2888,48 @@ } return JS_NewFloat64(ctx, audio->bpm); } +// Drum-skin friction: `sound.scratch({level, cutoff, resonance, roughness, +// release, pan, synthetic})` while a finger slides on the pad, and +// `sound.scratch(null)` (or level 0) when it lifts. One continuous voice, so +// the piece re-states it every control frame rather than starting notes. +// +// The material mapping — where on the head, how fast, how many fingers — is +// the piece's job (see padScratch in pieces/notepat.mjs), matching how +// MenuBandPercussion splits control from render on macOS. +static JSValue js_sound_scratch(JSContext *ctx, JSValueConst this_val, + int argc, JSValueConst *argv) { + (void)this_val; + ACAudio *audio = current_rt->audio; + if (!audio) return JS_UNDEFINED; + if (argc < 1 || !JS_IsObject(argv[0])) { + audio_scratch_stop(audio); + return JS_UNDEFINED; + } + double level = 0, cutoff = 1600, resonance = 150, roughness = 0.5; + double release = 0.014, pan = 0; + int synthetic = 0; + JSValue v; +#define SCRATCH_NUM(name, dest) \ + v = JS_GetPropertyStr(ctx, argv[0], name); \ + if (JS_IsNumber(v)) JS_ToFloat64(ctx, &dest, v); \ + JS_FreeValue(ctx, v); + SCRATCH_NUM("level", level) + SCRATCH_NUM("cutoff", cutoff) + SCRATCH_NUM("resonance", resonance) + SCRATCH_NUM("roughness", roughness) + SCRATCH_NUM("release", release) + SCRATCH_NUM("pan", pan) +#undef SCRATCH_NUM + v = JS_GetPropertyStr(ctx, argv[0], "synthetic"); + if (!JS_IsUndefined(v)) synthetic = JS_ToBool(ctx, v); + JS_FreeValue(ctx, v); + + if (level <= 0.0) audio_scratch_stop(audio); + else audio_scratch_set(audio, level, cutoff, resonance, roughness, + release, pan, synthetic); + return JS_UNDEFINED; +} + // --- DJ deck bindings --- static void blit_argb_nearest(ACFramebuffer *fb, const uint32_t *src, @@ -3078,6 +3120,7 @@ JS_SetPropertyStr(ctx, sound, "play", JS_NewCFunction(ctx, js_noop, "play", 2)); JS_SetPropertyStr(ctx, sound, "kill", JS_NewCFunction(ctx, js_sound_kill, "kill", 2)); JS_SetPropertyStr(ctx, sound, "update", JS_NewCFunction(ctx, js_sound_update, "update", 2)); JS_SetPropertyStr(ctx, sound, "bpm", JS_NewCFunction(ctx, js_sound_bpm, "bpm", 1)); + JS_SetPropertyStr(ctx, sound, "scratch", JS_NewCFunction(ctx, js_sound_scratch, "scratch", 1)); JS_SetPropertyStr(ctx, sound, "time", JS_NewFloat64(ctx, rt->audio ? rt->audio->time : 0.0)); JS_SetPropertyStr(ctx, sound, "registerSample", JS_NewCFunction(ctx, js_noop, "registerSample", 3)); diff --git a/fedac/native/src/scratch_voice.c b/fedac/native/src/scratch_voice.c new file mode 100644 --- /dev/null +++ b/fedac/native/src/scratch_voice.c @@ -0,0 +1,113 @@ +// scratch_voice.c — see scratch_voice.h for what this is and why it is +// dependency-free. +// +// The sound is two low-passed copies of ONE noise source subtracted from each +// other — a band whose centre and width follow the material under the finger — +// pushed through a tanh so the grip bites rather than hisses, plus a sine +// "head" carrier that the friction itself frequency-modulates a little. +// +// That last detail is the one worth not losing in a rewrite. Without it this +// is a filtered noise sweep and sounds like one. With it the head wobbles +// because the rubbing is uneven, which is why the gesture reads as a finger +// dragging on a skin rather than a knob being turned. + +#include "scratch_voice.h" + +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +static inline uint32_t scratch_xorshift(uint32_t *s) { + uint32_t x = *s ? *s : 0x51a7c4d3u; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + return *s = x; +} + +void scratch_voice_init(ScratchVoice *v) { + if (!v) return; + memset(v, 0, sizeof *v); + v->seed = 0x51a7c4d3u; // same stream Menu Band starts from + v->p.cutoff = 1600.0; + v->p.resonance = 150.0; + v->p.roughness = 0.5; + v->p.release = 0.014; +} + +void scratch_voice_set(ScratchVoice *v, const ScratchParams *p) { + if (!v || !p) return; + v->p = *p; + if (v->p.target < 0.0) v->p.target = 0.0; + if (v->p.roughness < 0.0) v->p.roughness = 0.0; + if (v->p.roughness > 1.0) v->p.roughness = 1.0; + if (v->p.release <= 0.0) v->p.release = 0.014; + if (v->p.pan < -1.0) v->p.pan = -1.0; + if (v->p.pan > 1.0) v->p.pan = 1.0; + if (v->seed == 0) v->seed = 0x51a7c4d3u; +} + +void scratch_voice_stop(ScratchVoice *v) { + if (!v) return; + v->p.target = 0.0; // the release ramp does the rest +} + +int scratch_voice_active(const ScratchVoice *v) { + if (!v) return 0; + return (v->p.target > 0.0) || (v->level >= 1e-5); +} + +void scratch_voice_render(ScratchVoice *v, double sample_rate, + double *out_l, double *out_r) { + if (!v || sample_rate <= 0.0) return; + + double attack_s = v->p.synthetic ? 0.006 : 0.0025; + double attack_a = 1.0 - exp(-1.0 / (sample_rate * attack_s)); + double release_a = 1.0 - exp(-1.0 / (sample_rate * v->p.release)); + + double cutoff = v->p.cutoff; + if (cutoff < 20.0) cutoff = 20.0; + if (cutoff > sample_rate * 0.45) cutoff = sample_rate * 0.45; + double slow_hz = cutoff * 0.18; + if (slow_hz < 35.0) slow_hz = 35.0; + double filter_a = 1.0 - exp(-2.0 * M_PI * cutoff / sample_rate); + double slow_a = 1.0 - exp(-2.0 * M_PI * slow_hz / sample_rate); + + double smoothing = (v->p.target > v->level) ? attack_a : release_a; + v->level += smoothing * (v->p.target - v->level); + + double white = ((double)scratch_xorshift(&v->seed) / (double)UINT32_MAX) * 2.0 - 1.0; + v->noise += filter_a * (white - v->noise); + v->slow_noise += slow_a * (white - v->slow_noise); + double friction = v->noise - v->slow_noise; + + // Physical surface only: the friction nudges the head's pitch. The electro + // surface keeps a steady carrier because its character is the ring + // modulation, not the grip. + double pitch_motion = v->p.synthetic ? 1.0 + : 1.0 + tanh(friction * 8.0) * 0.055; + double resonance = v->p.resonance; + if (resonance < 0.0) resonance = 0.0; + if (resonance > sample_rate * 0.45) resonance = sample_rate * 0.45; + v->phase += resonance * pitch_motion / sample_rate; + if (v->phase >= 1.0) v->phase -= floor(v->phase); + double carrier = sin(2.0 * M_PI * v->phase); + + double texture; + if (v->p.synthetic) { + texture = v->noise * carrier * 1.35; + } else { + double gnarl = tanh(friction * (5.0 + v->p.roughness * 5.0)); + texture = gnarl * 0.44 + + carrier * (0.08 + fabs(gnarl) + * (0.42 + v->p.roughness * 0.30)); + } + + double s = texture * v->level; + double theta = (v->p.pan + 1.0) * 0.25 * M_PI; + if (out_l) *out_l = s * cos(theta); + if (out_r) *out_r = s * sin(theta); +} diff --git a/fedac/native/src/scratch_voice.h b/fedac/native/src/scratch_voice.h new file mode 100644 --- /dev/null +++ b/fedac/native/src/scratch_voice.h @@ -0,0 +1,62 @@ +// scratch_voice.h — the drum head, rubbed rather than struck. +// +// Striking the pad is a note: it fires, it decays, it is over. Dragging a +// finger across it is not. That sound exists exactly as long as the finger +// keeps moving, and every property of it — how bright, how rough, how high the +// head sings — tracks where the finger is and how fast it is going. So it is +// one continuous voice with no note-on and no duration, driven by the control +// side re-stating it every frame. +// +// Ported from the scratch block in Menu Band's MenuBandPercussion.swift so the +// Mac and the machine make the same noise under the same finger. +// +// Dependency-free on purpose, the same way gm_synth.c and fluoddity_voice.c +// are: standard C only, no ALSA, no engine headers. That is what lets the +// voice be built and auditioned on a laptop (tools/scratch-audition.c) instead +// of only on the hardware it ships to. + +#ifndef SCRATCH_VOICE_H +#define SCRATCH_VOICE_H + +#include + +// What the control side asks for. Everything here is re-stated per frame; the +// voice slews toward it rather than jumping, so a gap between updates sustains +// instead of stuttering. +typedef struct { + double target; // 0..~0.22 amplitude asked for; 0 releases + double cutoff; // friction band centre, Hz (skin dull, rim bright) + double resonance; // head carrier, Hz — this is what gesture speed moves + double roughness; // 0..1 grip nonlinearity + double release; // fall time once the finger stops, seconds + double pan; // -1..1 + int synthetic; // 1 = the broader ring-modulated electro surface +} ScratchParams; + +typedef struct { + ScratchParams p; + double level; // slewed toward p.target; the actual amplitude + double noise; // fast one-pole noise state + double slow_noise; // slow one-pole state; the pair makes the band + double phase; // carrier phase, 0..1 + uint32_t seed; // xorshift state +} ScratchVoice; + +// Zero the state and seed the noise. Safe to call repeatedly. +void scratch_voice_init(ScratchVoice *v); + +// Replace the control parameters. Cheap; call once per control frame. +void scratch_voice_set(ScratchVoice *v, const ScratchParams *p); + +// Ask the voice to fall silent. The release ramp still runs. +void scratch_voice_stop(ScratchVoice *v); + +// 1 while the voice still has something to contribute — nothing asked for and +// nothing left ringing means the mixer can skip it entirely. +int scratch_voice_active(const ScratchVoice *v); + +// Render one sample into a stereo pair (accumulating is the caller's job). +void scratch_voice_render(ScratchVoice *v, double sample_rate, + double *out_l, double *out_r); + +#endif // SCRATCH_VOICE_H diff --git a/fedac/native/tools/scratch-audition.c b/fedac/native/tools/scratch-audition.c new file mode 100644 --- /dev/null +++ b/fedac/native/tools/scratch-audition.c @@ -0,0 +1,204 @@ +// scratch-audition.c — ear + number harness for the drum-skin friction voice. +// +// The friction is a gesture instrument: it only means anything while a finger +// is moving, so the useful test is not "does it make a sound" but "does the +// sound follow the hand". This renders the gestures that matter and prints the +// numbers that would betray a broken port. +// +// Sits beside pitch-audit.c and surface-audit.c: run it after touching the +// friction voice, the same way pitch-audit is run after touching the synth core. +// +// Build + run (from fedac/native/tools): +// cc -O2 -I ../src scratch-audition.c ../src/scratch_voice.c -lm \ +// -o /tmp/scratch-audition +// /tmp/scratch-audition ~/Desktop/scratch +// +// Outputs into the target dir: +// sweep.wav — a slow drag from the middle of the head to the metal edge +// speed.wav — the same spot rubbed at rising speed (the pitch law) +// material.wav — a step through the five materials at one speed +// release.wav — rub, then stop dead (does it let go, or hang?) +// synthetic.wav — the electro surface, for comparison + +#include "scratch_voice.h" + +#include +#include +#include +#include +#include +#include + +#define SR 48000 + +// ── Minimal stereo 16-bit WAV writer ── +static void wav_write(const char *path, const float *interleaved, long frames) { + FILE *f = fopen(path, "wb"); + if (!f) { fprintf(stderr, "can't write %s\n", path); exit(1); } + uint32_t data_bytes = (uint32_t)(frames * 2 * 2); + uint32_t riff = 36 + data_bytes; + uint16_t ch = 2, bits = 16, block = 4, fmt = 1; + uint32_t sr = SR, byterate = SR * 4, fmtlen = 16; + fwrite("RIFF", 1, 4, f); fwrite(&riff, 4, 1, f); fwrite("WAVE", 1, 4, f); + fwrite("fmt ", 1, 4, f); fwrite(&fmtlen, 4, 1, f); fwrite(&fmt, 2, 1, f); + fwrite(&ch, 2, 1, f); fwrite(&sr, 4, 1, f); fwrite(&byterate, 4, 1, f); + fwrite(&block, 2, 1, f); fwrite(&bits, 2, 1, f); + fwrite("data", 1, 4, f); fwrite(&data_bytes, 4, 1, f); + for (long i = 0; i < frames * 2; i++) { + double v = interleaved[i]; + if (v > 1.0) v = 1.0; + if (v < -1.0) v = -1.0; + int16_t s = (int16_t)lrint(v * 32767.0); + fwrite(&s, 2, 1, f); + } + fclose(f); +} + +// The piece's material mapping, mirrored here so the harness auditions what a +// finger would actually produce rather than arbitrary parameter values. Kept +// in step with padScratch* in fedac/native/pieces/notepat.mjs. +static double mixd(double a, double b, double t) { return a + (b - a) * t; } +static double smoothstep(double e0, double e1, double x) { + double t = (x - e0) / (e1 - e0); + if (t < 0) t = 0; + if (t > 1) t = 1; + return t * t * (3.0 - 2.0 * t); +} + +static ScratchParams material_at(double radius, double speed, int synthetic) { + double toSnare = smoothstep(0.23, 0.31, radius); + double toRim = smoothstep(0.40, 0.48, radius); + double toHat = smoothstep(0.62, 0.70, radius); + double toClick = smoothstep(0.88, 0.965, radius); + + double cutoff = mixd(175, 430, toSnare); + cutoff = mixd(cutoff, 680, toRim); + cutoff = mixd(cutoff, 1250, toHat); + cutoff = mixd(cutoff, 2050, toClick); + + double res = mixd(mixd(mixd(mixd(48, 90, toSnare), 185, toRim), 360, toHat), 560, toClick); + // Speed moves pitch in octaves, not hertz. + double octaves = speed * 0.82; + if (octaves > 2.25) octaves = 2.25; + res *= pow(2.0, octaves); + + double rough = mixd(0.30, 0.78, toSnare); + rough = mixd(rough, 0.48, toRim); + rough = mixd(rough, 0.70, toHat); + rough = mixd(rough, 0.38, toClick); + + double level = speed * 0.052; + if (level > 0.14) level = 0.14; + + ScratchParams p = { + .target = level, .cutoff = cutoff, .resonance = res, + .roughness = rough, .release = 0.010, .pan = 0.0, + .synthetic = synthetic, + }; + return p; +} + +// Render `seconds` while a callback restates the control params every 5 ms, +// the way a piece would once per frame. +typedef void (*GestureFn)(double t, ScratchParams *p); + +static long render_gesture(ScratchVoice *v, float *out, double seconds, GestureFn fn) { + long frames = (long)(SR * seconds); + long control_every = SR / 200; // 5 ms + for (long i = 0; i < frames; i++) { + if (i % control_every == 0) { + ScratchParams p = v->p; + fn((double)i / SR, &p); + scratch_voice_set(v, &p); + } + double l = 0, r = 0; + scratch_voice_render(v, SR, &l, &r); + out[i * 2] = (float)l; + out[i * 2 + 1] = (float)r; + } + return frames; +} + +static void stats(const char *name, const float *x, long frames) { + double peak = 0, rms = 0; + long nonfinite = 0; + for (long i = 0; i < frames * 2; i++) { + if (!isfinite(x[i])) { nonfinite++; continue; } + double m = fabs(x[i]); + if (m > peak) peak = m; + rms += x[i] * x[i]; + } + rms = sqrt(rms / (frames * 2)); + // Tail level over the last 50 ms — a friction voice that never lets go + // shows up here and nowhere else. + double tail = 0; + long tail_n = SR / 20; + for (long i = (frames - tail_n) * 2; i < frames * 2; i++) tail += x[i] * x[i]; + tail = sqrt(tail / (tail_n * 2)); + printf(" %-11s peak %.4f rms %.4f tail %.6f nonfinite %ld%s\n", + name, peak, rms, tail, nonfinite, nonfinite ? " <-- BAD" : ""); +} + +// ── Gestures ── +static void g_sweep(double t, ScratchParams *p) { + *p = material_at(t / 4.0, 0.9, 0); // centre → edge over 4 s +} +static void g_speed(double t, ScratchParams *p) { + *p = material_at(0.35, (t / 4.0) * 2.6, 0); // one spot, rising speed +} +static void g_material(double t, ScratchParams *p) { + static const double stops[5] = { 0.10, 0.36, 0.55, 0.75, 0.94 }; + int i = (int)(t / 1.0); + if (i > 4) i = 4; + *p = material_at(stops[i], 1.0, 0); +} +static void g_release(double t, ScratchParams *p) { + if (t < 1.5) *p = material_at(0.40, 1.2, 0); + else p->target = 0.0; // finger stops dead +} +static void g_synthetic(double t, ScratchParams *p) { + ScratchParams q = material_at(t / 4.0, 1.1, 1); + q.cutoff = 1200.0 + 9000.0 * (t / 4.0); + q.resonance = 1100.0 + q.cutoff * 0.42; + q.roughness = 0.5; + q.release = 0.018; + *p = q; +} + +int main(int argc, char **argv) { + const char *dir = argc > 1 ? argv[1] : "/tmp/scratch-audition"; + mkdir(dir, 0755); + + struct { const char *name; GestureFn fn; double secs; } takes[] = { + { "sweep", g_sweep, 4.0 }, + { "speed", g_speed, 4.0 }, + { "material", g_material, 5.0 }, + { "release", g_release, 2.5 }, + { "synthetic", g_synthetic, 4.0 }, + }; + + printf("drum-skin friction audition -> %s\n", dir); + float *buf = malloc(sizeof(float) * SR * 6 * 2); + int bad = 0; + for (unsigned i = 0; i < sizeof takes / sizeof *takes; i++) { + ScratchVoice v; + scratch_voice_init(&v); + long n = render_gesture(&v, buf, takes[i].secs, takes[i].fn); + char path[512]; + snprintf(path, sizeof path, "%s/%s.wav", dir, takes[i].name); + wav_write(path, buf, n); + stats(takes[i].name, buf, n); + // The release take is the one with a pass/fail: after a finger stops, + // the voice must actually be gone. + if (!strcmp(takes[i].name, "release")) { + double tail = 0; + long tn = SR / 20; + for (long k = (n - tn) * 2; k < n * 2; k++) tail += buf[k] * buf[k]; + tail = sqrt(tail / (tn * 2)); + if (tail > 1e-4) { printf(" release FAILED: voice still ringing\n"); bad = 1; } + else printf(" release ok: silent within a second of the finger stopping\n"); + } + } + free(buf); + return bad; +} -- tangled.sh