feat(audio): Web Audio voicing playback engine src/lib/chordAudio.js (task L-20)

playVoicing (piano.js absolute-note space, strummed, soft envelope),
guitarShapeToNotes (tab lists / open frets / movable rootStr+offsets via
the ChordDiagram convention), playChord pc fallback, stopAll. Dedicated
lazy AudioContext; zero coupling to the detection pipeline; mic-feedback
caveat documented. Critic PASS (19/19 independent pitch-math checks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-08 17:01:40 +01:00
parent 396c6ba562
commit 4b0f0c84a7
+304
View File
@@ -0,0 +1,304 @@
// ─── Chord audio preview engine (L-20) ────────────────────────────────────────
//
// A pure, React-free Web Audio module that AUDITIONS voicings — the sound behind
// the ▶ buttons in the Voicing Browser (D-21) and anywhere else the Knowledge
// Center wants a chord heard, not just seen.
//
// It owns ONE module-level, lazily-created AudioContext, spun up on the first
// play call (which, in the UI, is always a user gesture — required by browser
// autoplay policy). It NEVER touches the detection pipeline: no import from
// AudioCapture.jsx, audioService.js, or App.jsx, no analyser, no microphone.
// Playback goes straight to the speakers on its own context.
//
// ── ⚠ MIC-FEEDBACK CAVEAT (read before wiring a UI) ───────────────────────────
// This module plays chords out of the speakers. While the mic is live, the
// DETECTION pipeline (pitch + chroma analysers in AudioCapture) will HEAR this
// playback like any other sound in the room — a previewed voicing can vote into
// chord/key detection and pollute the session. UI callers should pause or
// ignore detection for the duration of a preview (roughly the `durMs` window
// after the last strummed note). Wiring that suppression is a FUTURE task —
// this module deliberately stays decoupled and just makes sound.
//
// ── Note space ────────────────────────────────────────────────────────────────
// All play functions take notes in the piano.js convention: absolute semitone
// positions where 0 = the C of the low displayed octave = C3 = MIDI 48
// (130.81 Hz). `pianoVoicing().notes` (036) drops straight in. Guitar shapes
// reach BELOW that anchor (open low E = E2 = MIDI 40 = note 8); negative
// values are legal input to `playVoicing` — only the renderers care about the
// 036 window, the synth maps any integer to its true frequency.
//
// ── Tone ──────────────────────────────────────────────────────────────────────
// Per note: two slightly-detuned triangle oscillators (a little chorus warmth)
// plus a quiet sine an octave up (shimmer), through a lowpass and a gain
// envelope (~20 ms soft attack, exponential release inside `durMs`). Simple,
// pleasant, no samples. A master compressor guards against clipping when many
// notes ring together.
// ─── Module state (the ONE lazy AudioContext) ─────────────────────────────────
let ctx = null // dedicated AudioContext — created on first play call only
let master = null // master gain → compressor → destination
const active = new Set() // live note handles: { g, oscs } — for stopAll()
const ATTACK_S = 0.02 // soft attack
const TAIL_S = 0.06 // oscillator stop margin after the envelope floor
const ENV_FLOOR = 0.0001 // exponential ramps can't reach 0
const MAX_NOTES = 12 // defensive cap on simultaneous scheduled notes
function ensureContext() {
if (!ctx) {
const AC = globalThis.AudioContext || globalThis.webkitAudioContext
if (!AC) throw new Error('chordAudio: Web Audio API not available in this environment')
ctx = new AC()
master = ctx.createGain()
master.gain.value = 0.9
const comp = ctx.createDynamicsCompressor()
comp.threshold.value = -18
comp.knee.value = 24
comp.ratio.value = 4
master.connect(comp)
comp.connect(ctx.destination)
}
// A suspended context (autoplay policy) resumes on the user-gesture play call.
if (ctx.state === 'suspended') ctx.resume()
return ctx
}
// ─── Pitch math ───────────────────────────────────────────────────────────────
const mod12 = (n) => ((n % 12) + 12) % 12
/**
* noteToFreq(note) → frequency in Hz.
*
* `note` is an absolute position in the piano.js space (0 = C3 = MIDI 48).
* Pure helper — exported so tests can verify the mapping without an
* AudioContext. noteToFreq(0) ≈ 130.81 (C3), noteToFreq(21) = 440 (A4).
*/
export function noteToFreq(note) {
return 440 * Math.pow(2, (note + 48 - 69) / 12)
}
// ─── Synthesis ────────────────────────────────────────────────────────────────
// Schedule one note: detuned triangle pair + octave sine → lowpass → envelope.
function scheduleNote(note, when, durMs, peakGain) {
const freq = noteToFreq(note)
const durS = Math.max(0.1, durMs / 1000)
const g = ctx.createGain()
g.gain.setValueAtTime(ENV_FLOOR, when)
g.gain.linearRampToValueAtTime(peakGain, when + ATTACK_S)
g.gain.exponentialRampToValueAtTime(ENV_FLOOR, when + durS)
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = Math.min(freq * 7, 5500) // keep it warm, not raw
lp.Q.value = 0.4
const partials = [
{ type: 'triangle', ratio: 1, detune: 0, level: 1.0 },
{ type: 'triangle', ratio: 1, detune: 6, level: 0.35 }, // +6 cents chorus
{ type: 'sine', ratio: 2, detune: 0, level: 0.18 }, // octave shimmer
]
const oscs = partials.map((p) => {
const osc = ctx.createOscillator()
osc.type = p.type
osc.frequency.value = freq * p.ratio
osc.detune.value = p.detune
const og = ctx.createGain()
og.gain.value = p.level
osc.connect(og)
og.connect(lp)
osc.start(when)
osc.stop(when + durS + TAIL_S)
return osc
})
lp.connect(g)
g.connect(master)
const handle = { g, oscs }
active.add(handle)
oscs[0].onended = () => active.delete(handle) // GC the handle when done
return handle
}
// Release one handle now (quick fade, then hard-stop the oscillators).
function releaseHandle(handle, now) {
try {
handle.g.gain.cancelScheduledValues(now)
handle.g.gain.setValueAtTime(Math.max(handle.g.gain.value, ENV_FLOOR), now)
handle.g.gain.linearRampToValueAtTime(ENV_FLOOR, now + 0.04)
for (const osc of handle.oscs) osc.stop(now + TAIL_S)
} catch {
// node already stopped — nothing to release
}
active.delete(handle)
}
// ─── Public API ───────────────────────────────────────────────────────────────
/**
* playVoicing(notes, opts?) → { stop() }
*
* Plays a voicing — the primary entry point. `notes` are absolute positions in
* the piano.js space (0 = C3 = MIDI 48; `pianoVoicing().notes` drops straight
* in; `guitarShapeToNotes(...)` output too, including its below-C3 negatives).
*
* @param {number[]} notes absolute semitone positions (0 = C3)
* @param {{strumMs?: number, durMs?: number, gain?: number}} [opts]
* strumMs — stagger between note onsets, low→high (default 30; 0 = block chord)
* durMs — each note's envelope length (default 1800)
* gain — overall loudness 01 (default 0.5), split across the notes
* @returns {{ stop: () => void }} releases just THIS chord (stopAll() gets everything)
*
* First call creates the AudioContext — call from a user gesture (click).
*/
export function playVoicing(notes, opts = {}) {
const { strumMs = 30, durMs = 1800, gain = 0.5 } = opts
const clean = [...new Set((Array.isArray(notes) ? notes : []).filter(Number.isFinite))]
.sort((a, b) => a - b)
.slice(0, MAX_NOTES)
if (clean.length === 0) return { stop() {} }
ensureContext()
const now = ctx.currentTime
// Split the chord's loudness across its voices (sqrt: correlated-ish summing).
const perNote = Math.min(0.6, gain / Math.sqrt(clean.length))
const handles = clean.map((note, i) =>
scheduleNote(note, now + (i * strumMs) / 1000, durMs, perNote)
)
return {
stop() {
if (!ctx) return
const t = ctx.currentTime
for (const h of handles) releaseHandle(h, t)
},
}
}
// Standard-tuning open-string MIDI numbers, strings [s6 … s1] = E2 A2 D3 G3 B3 E4.
// (voicings.js' OPEN holds the same strings as pitch classes; here we need real
// octaves to make sound, so the octaved values live here — same tuning, same order.)
const OPEN_STRING_MIDI = [40, 45, 50, 55, 59, 64]
/**
* guitarShapeToNotes(shape, opts?) → number[]
*
* Maps a guitar voicing to the shared absolute-note space (0 = C3 = MIDI 48) so
* guitar shapes are playable via playVoicing. Low guitar strings sit BELOW C3,
* so the result can contain negatives (open low E = 8) — legal for playback.
* Returns sorted, deduped notes; [] for anything unrecognisable.
*
* Accepted shapes (everything voicings.js and the lick schema produce):
* 1. `[{string, fret}, …]` — note list (lick `tab` form); string 6 = low E,
* string 1 = high e; entries with a non-finite
* fret or string outside 16 are skipped.
* 2. `{frets: [s6…s1]}` — absolute frets, 'x'/null = muted. This is both
* voicings.js' open form and every resolved
* `getGuitarVoicings()` entry.
* 3. `{rootStr, offsets: [s6…s1]}` — voicings.js movable/barre form; offsets are
* fret distances from the root fret. Needs the
* key: pass `opts.rootPc` (011) to place it
* (falls back to shape.rootPc, then C).
*
* @param {Array|Object} shape
* @param {{rootPc?: number}} [opts] root pitch class for movable shapes
* @returns {number[]} absolute notes (may be negative), sorted low→high
*/
export function guitarShapeToNotes(shape, opts = {}) {
if (!shape) return []
// Form 1: [{string, fret}, …]
if (Array.isArray(shape)) {
const notes = []
for (const entry of shape) {
const s = entry?.string
const f = entry?.fret
if (!Number.isFinite(s) || s < 1 || s > 6 || !Number.isFinite(f) || f < 0) continue
notes.push(OPEN_STRING_MIDI[6 - s] + f - 48)
}
return [...new Set(notes)].sort((a, b) => a - b)
}
// Resolve the per-string absolute frets [s6…s1].
let frets = null
if (Array.isArray(shape.frets)) {
// Form 2: open shape / resolved getGuitarVoicings entry.
frets = shape.frets
} else if (Array.isArray(shape.offsets) && Number.isFinite(shape.rootStr)) {
// Form 3: movable shape — offsets relative to the root fret on rootStr.
// rootStr: 6 = low E … 1 = high e → low-E-first index is 6 rootStr
// (the same convention ChordDiagram.jsx renders with).
const idx = 6 - shape.rootStr
if (idx < 0 || idx > 5) return []
const rootPc = mod12(opts.rootPc ?? shape.rootPc ?? 0)
const openPc = mod12(OPEN_STRING_MIDI[idx])
const rootFret = mod12(rootPc - openPc)
frets = shape.offsets.map((off) => (Number.isFinite(off) ? rootFret + off : 'x'))
// Negative-offset shapes (e.g. the G shape) can dip below fret 0 near the
// nut — slide the WHOLE shape up an octave rather than dropping strings.
const finite = frets.filter(Number.isFinite)
if (finite.length && Math.min(...finite) < 0) {
frets = frets.map((f) => (Number.isFinite(f) ? f + 12 : f))
}
}
if (!frets) return []
const notes = []
for (let i = 0; i < Math.min(6, frets.length); i++) {
const f = frets[i]
if (!Number.isFinite(f) || f < 0) continue // 'x' / muted / impossible
notes.push(OPEN_STRING_MIDI[i] + f - 48)
}
return [...new Set(notes)].sort((a, b) => a - b)
}
/**
* playChord(pcs, octave?, opts?) → { stop() }
*
* Simple fallback when all you have is a bare pitch-class set (e.g. a detected
* chord's tones, no voicing). Stacks the pcs ASCENDING from the first pc, which
* is treated as the bass: each subsequent pc lands in the nearest position
* above the previous note — so [7, 11, 5] plays G3-B3-F4, a real shell, not a
* scrambled cluster. `octave` places the bass (4 = the octave starting at
* C4 = middle C; default 4; piano.js note = (octave 3) * 12 + pc).
*
* @param {number[]} pcs pitch classes 011, first entry = bass
* @param {number} [octave] bass octave (scientific pitch, default 4)
* @param {{strumMs?: number, durMs?: number, gain?: number}} [opts] as playVoicing
* @returns {{ stop: () => void }}
*/
export function playChord(pcs, octave = 4, opts = {}) {
const clean = (Array.isArray(pcs) ? pcs : []).filter(Number.isFinite).map(mod12)
if (clean.length === 0) return { stop() {} }
const base = (octave - 3) * 12
const notes = [base + clean[0]]
for (let i = 1; i < clean.length; i++) {
const prev = notes[i - 1]
let n = base + clean[i]
while (n <= prev) n += 12 // nearest position strictly above the last voice
notes.push(n)
}
return playVoicing(notes, opts)
}
/**
* stopAll()
*
* Immediately releases everything currently sounding or scheduled (quick 40 ms
* fade to avoid clicks, then hard-stops the oscillators). Safe to call before
* the first play (no-op) and repeatedly. Typical UI pattern: stopAll() before
* each playVoicing so previews never layer.
*/
export function stopAll() {
if (!ctx) return
const now = ctx.currentTime
for (const handle of [...active]) releaseHandle(handle, now)
active.clear()
}