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; +}