Skip to content

Commit 919f7ed

Browse files
committed
Elaborate examples UI
1 parent a4658f1 commit 919f7ed

25 files changed

Lines changed: 638 additions & 389 deletions

examples/_util.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Shared helpers for examples: arg parsing, numeric/time parsing, TTY key handling, live status line.
2+
3+
let semi = 'C.D.EF.G.A.B'
4+
5+
export let num = v => {
6+
v += ''
7+
let m = v.match(/^([A-G])([#b])?(-?\d)$/i)
8+
if (m) return 440 * 2 ** ((semi.indexOf(m[1].toUpperCase()) + (m[2] === '#') - (m[2] === 'b') + 12 * (+m[3] + 1) - 69) / 12)
9+
return parseFloat(v) * (/k$/i.test(v) ? 1e3 : 1)
10+
}
11+
12+
export let sec = v => (v += '', parseFloat(v) * ({ s: 1, m: 60, h: 3600 }[v.slice(-1)] || 1))
13+
14+
// parse argv: positional tokens + k=v pairs + -d/--duration/--key=val long flags
15+
export let args = (argv = process.argv.slice(2)) => {
16+
let kv = {}, pos = []
17+
for (let i = 0; i < argv.length; i++) {
18+
let s = argv[i]
19+
if (s === '-d' || s === '--duration') { kv.dur = argv[++i]; continue }
20+
if (s.startsWith('--')) {
21+
let e = s.indexOf('=')
22+
if (e > 0) kv[s.slice(2, e)] = s.slice(e + 1)
23+
else kv[s.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true
24+
continue
25+
}
26+
let e = s.indexOf('=')
27+
if (e > 0) kv[s.slice(0, e)] = s.slice(e + 1)
28+
else pos.push(s)
29+
}
30+
let $ = (k, d) => { for (let p in kv) if (k.startsWith(p) || p.startsWith(k)) return kv[p]; return d }
31+
return { kv, pos, $ }
32+
}
33+
34+
// Raw-mode keyboard. bindings: { up, down, left, right, space, enter, '+', '-', letters }.
35+
// Always binds q / Ctrl-C / Esc to quit. If ctx is passed, space toggles suspend/resume unless overridden.
36+
export let keys = (bindings = {}, onQuit, ctx) => {
37+
let s = process.stdin
38+
let restore = () => { try { s.isTTY && s.setRawMode(false); s.pause() } catch {} }
39+
let quit = () => { restore(); onQuit?.(); process.exit(0) }
40+
// If ctx is passed and space is not explicitly bound, wire pause/resume.
41+
if (ctx && !bindings.space) {
42+
bindings.space = async () => {
43+
if (ctx.state === 'running') await ctx.suspend()
44+
else if (ctx.state === 'suspended') await ctx.resume()
45+
}
46+
}
47+
if (!s.isTTY) return quit
48+
s.setRawMode(true); s.resume(); s.setEncoding('utf8')
49+
let map = { '\u001b[A': 'up', '\u001b[B': 'down', '\u001b[C': 'right', '\u001b[D': 'left', ' ': 'space', '\r': 'enter', '\t': 'tab' }
50+
s.on('data', k => {
51+
if (k === '\u0003' || k === 'q' || k === 'Q' || k === '\u001b') return quit()
52+
let name = map[k] || k
53+
bindings[name]?.(k)
54+
})
55+
process.on('exit', restore)
56+
return quit
57+
}
58+
59+
// Live single-line status: returns a function that overwrites the same terminal line.
60+
export let status = () => {
61+
let last = ''
62+
return s => {
63+
if (s === last) return
64+
last = s
65+
if (process.stdout.isTTY) process.stdout.write('\r\x1b[K' + s)
66+
else process.stdout.write(s + '\n')
67+
}
68+
}
69+
70+
export let clearLine = () => process.stdout.isTTY && process.stdout.write('\n')
71+
72+
// "paused" tag for status lines — inserts a marker when ctx is suspended.
73+
export let pausedTag = ctx => ctx.state === 'suspended' ? ' ⏸ PAUSED' : ''
74+
75+
// Nearest-note name for a frequency (for display).
76+
export let noteName = f => {
77+
let n = Math.round(12 * Math.log2(f / 440) + 69)
78+
let names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
79+
return names[((n % 12) + 12) % 12] + (Math.floor(n / 12) - 1)
80+
}

examples/additive.js

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
// Additive synthesis — build waveforms from individual harmonics.
22
// Run: node examples/additive.js square 220 16 3s
3-
// Run: node examples/additive.js wave=saw freq=1k n=32 dur=5s
3+
// Run: node examples/additive.js wave=saw freq=1k n=32 -d 5s
4+
// Keys: q quit
45

56
import { AudioContext } from 'web-audio-api'
7+
import { args, num, sec, keys, clearLine } from './_util.js'
68

7-
let args = process.argv.slice(2), kv = {}, pos = []
8-
for (let s of args) { let e = s.indexOf('='); e > 0 ? kv[s.slice(0, e)] = s.slice(e + 1) : pos.push(s) }
9-
let $ = (k, d) => { for (let p in kv) if (k.startsWith(p) || p.startsWith(k)) return kv[p]; return d }
10-
let semi = 'C.D.EF.G.A.B'
11-
let num = v => { v += ''; let m = v.match(/^([A-G])([#b])?(\d)$/i); return m ? 440 * 2 ** ((semi.indexOf(m[1].toUpperCase()) + (m[2]==='#') - (m[2]==='b') + 12*(+m[3]+1) - 69) / 12) : parseFloat(v) * (/k$/i.test(v) ? 1e3 : 1) }
12-
let sec = v => (v += '', parseFloat(v) * ({s:1,m:60,h:3600}[v.slice(-1)] || 1))
13-
9+
let { pos, $ } = args()
1410
let wave = pos.find(t => /^[a-z]/i.test(t) && !/^[A-G][#b]?\d$/i.test(t)) || $('wave', 'square')
1511
let nums = pos.filter(t => /^\d/.test(t) && !/[smh]$/.test(t) || /^[A-G][#b]?\d$/i.test(t))
1612
let f = num(nums[0] || $('freq', 220))
@@ -24,12 +20,11 @@ let master = ctx.createGain()
2420
master.gain.value = 0.3
2521
master.connect(ctx.destination)
2622

27-
// Harmonic recipes — Fourier series coefficients
2823
let amp = h => {
2924
switch (wave) {
30-
case 'square': return h % 2 ? 1 / h : 0 // odd harmonics only
31-
case 'saw': return 1 / h // all harmonics
32-
case 'triangle': return h % 2 ? ((-1) ** ((h - 1) / 2)) / (h * h) : 0 // odd, 1/h²
25+
case 'square': return h % 2 ? 1 / h : 0
26+
case 'saw': return 1 / h
27+
case 'triangle': return h % 2 ? ((-1) ** ((h - 1) / 2)) / (h * h) : 0
3328
default: return 1 / h
3429
}
3530
}
@@ -46,9 +41,10 @@ for (let h = 1; h <= n; h++) {
4641
osc.stop(ctx.currentTime + dur + 0.01)
4742
}
4843

49-
console.log(`Additive ${wave}: ${f}Hz, ${n} harmonics`)
44+
keys({}, () => { clearLine(); ctx.close() }, ctx)
45+
console.log(`Additive ${wave}: ${f}Hz, ${n} harmonics (${dur}s) space pause · q quit`)
5046

5147
let t = ctx.currentTime + dur
5248
master.gain.setValueAtTime(0.3, t - 0.1)
5349
master.gain.linearRampToValueAtTime(0, t)
54-
setTimeout(() => ctx.close(), dur * 1000)
50+
setTimeout(() => { clearLine(); ctx.close(); process.exit(0) }, dur * 1000)

examples/beating.js

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,47 @@
11
// Beating — two close frequencies create amplitude modulation.
22
// Run: node examples/beating.js 440 3 5s
3-
// Run: node examples/beating.js freq=440 diff=3 dur=5s
3+
// Run: node examples/beating.js freq=440 diff=3 -d 5s
4+
// Keys: ←/→ ±0.5 Hz beat · ↑/↓ ±semitone carrier · q quit
45

56
import { AudioContext } from 'web-audio-api'
7+
import { args, num, sec, keys, status, clearLine, pausedTag } from './_util.js'
68

7-
let args = process.argv.slice(2), kv = {}, pos = []
8-
for (let s of args) { let e = s.indexOf('='); e > 0 ? kv[s.slice(0, e)] = s.slice(e + 1) : pos.push(s) }
9-
let $ = (k, d) => { for (let p in kv) if (k.startsWith(p) || p.startsWith(k)) return kv[p]; return d }
10-
let semi = 'C.D.EF.G.A.B'
11-
let num = v => { v += ''; let m = v.match(/^([A-G])([#b])?(\d)$/i); return m ? 440 * 2 ** ((semi.indexOf(m[1].toUpperCase()) + (m[2]==='#') - (m[2]==='b') + 12*(+m[3]+1) - 69) / 12) : parseFloat(v) * (/k$/i.test(v) ? 1e3 : 1) }
12-
let sec = v => (v += '', parseFloat(v) * ({s:1,m:60,h:3600}[v.slice(-1)] || 1))
13-
9+
let { pos, $ } = args()
1410
let nums = pos.filter(t => /^\d/.test(t) && !/[smh]$/.test(t) || /^[A-G][#b]?\d$/i.test(t))
1511
let f = num(nums[0] || $('freq', 440))
1612
let diff = +(nums[1] || $('diff', 3))
17-
let dur = sec(pos.find(t => /\d[smh]$/.test(t)) || $('dur', '5'))
13+
let dur = sec(pos.find(t => /\d[smh]$/.test(t)) || $('dur', '30'))
1814

1915
let ctx = new AudioContext()
2016
await ctx.resume()
2117

22-
let osc1 = ctx.createOscillator()
18+
let osc1 = ctx.createOscillator(), osc2 = ctx.createOscillator()
2319
osc1.frequency.value = f
24-
25-
let osc2 = ctx.createOscillator()
2620
osc2.frequency.value = f + diff
27-
2821
let master = ctx.createGain()
2922
master.gain.value = 0.3
30-
osc1.connect(master)
31-
osc2.connect(master)
32-
master.connect(ctx.destination)
33-
23+
osc1.connect(master); osc2.connect(master); master.connect(ctx.destination)
3424
osc1.start(); osc2.start()
3525

36-
console.log(`${f}Hz + ${f + diff}Hz → ${diff}Hz beating`)
26+
let retune = () => {
27+
let t = ctx.currentTime
28+
osc1.frequency.setTargetAtTime(f, t, 0.02)
29+
osc2.frequency.setTargetAtTime(f + diff, t, 0.02)
30+
}
31+
32+
let render = status()
33+
let ui = setInterval(() => render(`${f.toFixed(2)}Hz + ${(f + diff).toFixed(2)}Hz · beat ${diff.toFixed(2)}Hz · space pause · ←→ beat · ↑↓ carrier · q quit${pausedTag(ctx)}`), 80)
34+
35+
keys({
36+
left: () => { diff = Math.max(0.1, diff - 0.5); retune() },
37+
right: () => { diff += 0.5; retune() },
38+
up: () => { f *= 2 ** (1/12); retune() },
39+
down: () => { f *= 2 ** (-1/12); retune() },
40+
}, () => { clearInterval(ui); clearLine(); ctx.close() }, ctx)
41+
42+
console.log(`${f}Hz + ${f + diff}Hz → ${diff}Hz beating (${dur}s) ←→ beat · ↑↓ carrier · q quit`)
3743

3844
let t = ctx.currentTime + dur
3945
master.gain.setValueAtTime(0.3, t - 0.05)
4046
master.gain.linearRampToValueAtTime(0, t)
41-
setTimeout(() => ctx.close(), dur * 1000)
47+
setTimeout(() => { clearInterval(ui); clearLine(); ctx.close(); process.exit(0) }, dur * 1000)

examples/binaural-beats.js

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,51 @@
11
// Binaural beats — slightly different frequencies in each ear.
22
// Run: node examples/binaural-beats.js 200 10 10s
3-
// Run: node examples/binaural-beats.js freq=200 beat=10 dur=10s
3+
// Run: node examples/binaural-beats.js freq=200 beat=10 -d 10s
4+
// Keys: ←/→ ±0.5 Hz beat · ↑/↓ ±semitone carrier · q quit
45

56
import { AudioContext } from 'web-audio-api'
7+
import { args, num, sec, keys, status, clearLine, pausedTag } from './_util.js'
68

7-
let args = process.argv.slice(2), kv = {}, pos = []
8-
for (let s of args) { let e = s.indexOf('='); e > 0 ? kv[s.slice(0, e)] = s.slice(e + 1) : pos.push(s) }
9-
let $ = (k, d) => { for (let p in kv) if (k.startsWith(p) || p.startsWith(k)) return kv[p]; return d }
10-
let semi = 'C.D.EF.G.A.B'
11-
let num = v => { v += ''; let m = v.match(/^([A-G])([#b])?(\d)$/i); return m ? 440 * 2 ** ((semi.indexOf(m[1].toUpperCase()) + (m[2]==='#') - (m[2]==='b') + 12*(+m[3]+1) - 69) / 12) : parseFloat(v) * (/k$/i.test(v) ? 1e3 : 1) }
12-
let sec = v => (v += '', parseFloat(v) * ({s:1,m:60,h:3600}[v.slice(-1)] || 1))
13-
9+
let { pos, $ } = args()
1410
let nums = pos.filter(t => /^\d/.test(t) && !/[smh]$/.test(t) || /^[A-G][#b]?\d$/i.test(t))
1511
let f = num(nums[0] || $('freq', 200))
1612
let beat = +(nums[1] || $('beat', 10))
17-
let dur = sec(pos.find(t => /\d[smh]$/.test(t)) || $('dur', '10'))
13+
let dur = sec(pos.find(t => /\d[smh]$/.test(t)) || $('dur', '60'))
1814

1915
let ctx = new AudioContext()
2016
await ctx.resume()
2117

22-
// Left ear: base frequency
23-
let oscL = ctx.createOscillator()
18+
let oscL = ctx.createOscillator(), oscR = ctx.createOscillator()
2419
oscL.frequency.value = f
25-
let panL = ctx.createStereoPanner()
26-
panL.pan.value = -1
27-
28-
// Right ear: base + beat frequency
29-
let oscR = ctx.createOscillator()
3020
oscR.frequency.value = f + beat
31-
let panR = ctx.createStereoPanner()
32-
panR.pan.value = 1
33-
21+
let panL = ctx.createStereoPanner(), panR = ctx.createStereoPanner()
22+
panL.pan.value = -1; panR.pan.value = 1
3423
let master = ctx.createGain()
3524
master.gain.value = 0.3
3625
oscL.connect(panL).connect(master)
3726
oscR.connect(panR).connect(master)
3827
master.connect(ctx.destination)
39-
4028
oscL.start(); oscR.start()
4129

42-
console.log(`L: ${f}Hz R: ${f + beat}Hz beat: ${beat}Hz — use headphones`)
30+
let retune = () => {
31+
let t = ctx.currentTime
32+
oscL.frequency.setTargetAtTime(f, t, 0.02)
33+
oscR.frequency.setTargetAtTime(f + beat, t, 0.02)
34+
}
35+
36+
let render = status()
37+
let ui = setInterval(() => render(`L ${f.toFixed(2)}Hz · R ${(f + beat).toFixed(2)}Hz · beat ${beat.toFixed(2)}Hz · space pause · ←→ beat · ↑↓ carrier · q quit${pausedTag(ctx)}`), 80)
38+
39+
keys({
40+
left: () => { beat = Math.max(0.1, beat - 0.5); retune() },
41+
right: () => { beat += 0.5; retune() },
42+
up: () => { f *= 2 ** (1/12); retune() },
43+
down: () => { f *= 2 ** (-1/12); retune() },
44+
}, () => { clearInterval(ui); clearLine(); ctx.close() }, ctx)
45+
46+
console.log(`L: ${f}Hz R: ${f + beat}Hz beat: ${beat}Hz — use headphones (${dur}s)`)
4347

4448
let t = ctx.currentTime + dur
4549
master.gain.setValueAtTime(0.3, t - 0.05)
4650
master.gain.linearRampToValueAtTime(0, t)
47-
setTimeout(() => ctx.close(), dur * 1000)
51+
setTimeout(() => { clearInterval(ui); clearLine(); ctx.close(); process.exit(0) }, dur * 1000)

examples/drone.js

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,88 @@
11
// Drone — tanpura-like harmonic drone with natural beating.
22
// Four strings: Pa, Sa, Sa (detuned), Sa (low octave).
33
// Run: node examples/drone.js 130.81 30s
4-
// Run: node examples/drone.js freq=261.63 dur=2m
4+
// Run: node examples/drone.js freq=C3 -d 2m
5+
// Keys: space pause · ↑/↓ ±semitone · ←/→ ±5 cents · 1-7 scale degrees · q quit
56

67
import { AudioContext } from 'web-audio-api'
8+
import { args, num, sec, keys, status, clearLine, noteName, pausedTag } from './_util.js'
79

8-
let args = process.argv.slice(2), kv = {}, pos = []
9-
for (let s of args) { let e = s.indexOf('='); e > 0 ? kv[s.slice(0, e)] = s.slice(e + 1) : pos.push(s) }
10-
let $ = (k, d) => { for (let p in kv) if (k.startsWith(p) || p.startsWith(k)) return kv[p]; return d }
11-
let semi = 'C.D.EF.G.A.B'
12-
let num = v => { v += ''; let m = v.match(/^([A-G])([#b])?(\d)$/i); return m ? 440 * 2 ** ((semi.indexOf(m[1].toUpperCase()) + (m[2]==='#') - (m[2]==='b') + 12*(+m[3]+1) - 69) / 12) : parseFloat(v) * (/k$/i.test(v) ? 1e3 : 1) }
13-
let sec = v => (v += '', parseFloat(v) * ({s:1,m:60,h:3600}[v.slice(-1)] || 1))
10+
let { pos, $ } = args()
1411

1512
let f = num(pos.find(t => /^\d/.test(t) && !/[smh]$/.test(t) || /^[A-G][#b]?\d$/i.test(t)) || $('freq', 130.81))
16-
let dur = sec(pos.find(t => /\d[smh]$/.test(t)) || $('dur', '30'))
13+
let dur = sec(pos.find(t => /\d[smh]$/.test(t)) || $('dur', '300'))
1714

1815
let ctx = new AudioContext()
1916
await ctx.resume()
2017

21-
// Tanpura tuning: Pa (5th), Sa (root), Sa (micro-detuned), Sa (octave below)
22-
let strings = [
23-
{ freq: f * 3 / 2 }, // Pa
24-
{ freq: f }, // Sa
25-
{ freq: f * 1.001 }, // Sa — beating against the other Sa
26-
{ freq: f / 2 }, // Sa low octave
27-
]
28-
2918
let master = ctx.createGain()
3019
master.connect(ctx.destination)
3120

32-
// Each string: harmonics 1-10 with micro-detuning for shimmer
33-
// The jwari (bridge buzz) emphasizes higher harmonics
34-
for (let { freq } of strings) {
35-
for (let h = 1; h <= 10; h++) {
36-
let osc = ctx.createOscillator()
37-
osc.frequency.value = freq * h * (1 + (Math.random() - 0.5) * 0.0005)
38-
let amp = h <= 3 ? 1 / h : 0.7 / h
39-
let g = ctx.createGain()
40-
g.gain.value = amp
41-
osc.connect(g).connect(master)
42-
osc.start()
43-
osc.stop(ctx.currentTime + dur + 0.1)
21+
let strings = []
22+
let build = freq => {
23+
// Tanpura tuning: Pa (5th), Sa, Sa detuned, Sa low
24+
let ratios = [3 / 2, 1, 1.001, 0.5]
25+
return ratios.map(r => {
26+
let stringFreq = freq * r
27+
let harmonics = []
28+
for (let h = 1; h <= 10; h++) {
29+
let osc = ctx.createOscillator()
30+
osc.frequency.value = stringFreq * h * (1 + (Math.random() - 0.5) * 0.0005)
31+
let amp = h <= 3 ? 1 / h : 0.7 / h
32+
let g = ctx.createGain()
33+
g.gain.value = amp
34+
osc.connect(g).connect(master)
35+
osc.start()
36+
harmonics.push({ osc, h, r })
37+
}
38+
return harmonics
39+
}).flat()
40+
}
41+
42+
strings = build(f)
43+
44+
let retune = freq => {
45+
f = freq
46+
let t = ctx.currentTime
47+
for (let { osc, h, r } of strings) {
48+
osc.frequency.setTargetAtTime(freq * r * h * (1 + (Math.random() - 0.5) * 0.0005), t, 0.08)
4449
}
4550
}
4651

47-
// Gentle fade in / out
4852
let t0 = ctx.currentTime
4953
master.gain.setValueAtTime(0, t0)
5054
master.gain.linearRampToValueAtTime(0.08, t0 + 2)
51-
master.gain.setValueAtTime(0.08, t0 + dur - 2)
52-
master.gain.linearRampToValueAtTime(0, t0 + dur)
5355

54-
console.log(`Drone: Sa = ${f.toFixed(1)}Hz (${dur}s)`)
55-
setTimeout(() => ctx.close(), dur * 1000 + 200)
56+
let render = status()
57+
let draw = () => render(`Sa = ${f.toFixed(2)}Hz ${noteName(f).padEnd(4)} ↑↓ semi · ←→ cents · 1-7 scale · space pause · q quit${pausedTag(ctx)}`)
58+
let ui = setInterval(draw, 80)
59+
60+
let scale = [0, 2, 4, 5, 7, 9, 11] // C major semitone offsets
61+
let base = f
62+
let semiShift = v => retune(base * 2 ** (v / 12))
63+
64+
keys({
65+
up: () => { base = f * 2 ** (1 / 12); retune(base) },
66+
down: () => { base = f * 2 ** (-1 / 12); retune(base) },
67+
right: () => { base = f * 2 ** (5 / 1200); retune(base) }, // +5 cents
68+
left: () => { base = f * 2 ** (-5 / 1200); retune(base) },
69+
1: () => semiShift(scale[0]), 2: () => semiShift(scale[1]), 3: () => semiShift(scale[2]),
70+
4: () => semiShift(scale[3]), 5: () => semiShift(scale[4]), 6: () => semiShift(scale[5]),
71+
7: () => semiShift(scale[6]),
72+
}, () => {
73+
clearInterval(ui); clearLine()
74+
let t = ctx.currentTime
75+
master.gain.cancelScheduledValues(t)
76+
master.gain.setValueAtTime(master.gain.value, t)
77+
master.gain.linearRampToValueAtTime(0, t + 0.3)
78+
setTimeout(() => ctx.close(), 400)
79+
}, ctx)
80+
81+
console.log(`Drone: Sa = ${f.toFixed(2)}Hz ${noteName(f)} (${dur}s) ↑↓ semi · ←→ cents · 1-7 scale · q quit`)
82+
83+
setTimeout(() => {
84+
let t = ctx.currentTime
85+
master.gain.setValueAtTime(master.gain.value, t)
86+
master.gain.linearRampToValueAtTime(0, t + 2)
87+
}, (dur - 2) * 1000)
88+
setTimeout(() => { clearInterval(ui); clearLine(); ctx.close(); process.exit(0) }, dur * 1000 + 200)

0 commit comments

Comments
 (0)