|
1 | 1 | // Drone — tanpura-like harmonic drone with natural beating. |
2 | 2 | // Four strings: Pa, Sa, Sa (detuned), Sa (low octave). |
3 | 3 | // 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 |
5 | 6 |
|
6 | 7 | import { AudioContext } from 'web-audio-api' |
| 8 | +import { args, num, sec, keys, status, clearLine, noteName, pausedTag } from './_util.js' |
7 | 9 |
|
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() |
14 | 11 |
|
15 | 12 | 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')) |
17 | 14 |
|
18 | 15 | let ctx = new AudioContext() |
19 | 16 | await ctx.resume() |
20 | 17 |
|
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 | | - |
29 | 18 | let master = ctx.createGain() |
30 | 19 | master.connect(ctx.destination) |
31 | 20 |
|
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) |
44 | 49 | } |
45 | 50 | } |
46 | 51 |
|
47 | | -// Gentle fade in / out |
48 | 52 | let t0 = ctx.currentTime |
49 | 53 | master.gain.setValueAtTime(0, t0) |
50 | 54 | master.gain.linearRampToValueAtTime(0.08, t0 + 2) |
51 | | -master.gain.setValueAtTime(0.08, t0 + dur - 2) |
52 | | -master.gain.linearRampToValueAtTime(0, t0 + dur) |
53 | 55 |
|
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