Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/dev/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,128 @@ console.log('\n\x1b[1m10. Vibrato analysis — rate (Hz) & extent (¢) measureme
}
}

// ---------------------------------------------------------------------------
console.log('\n\x1b[1m11. ASTC (Howell 2016) — absolute tone-color scale & spectral bundles\x1b[0m');
{
const fmtB = (b) => `${b.label}${b.weakBridge ? '(wb)' : ''} ×${b.complexity} c=${b.centroid.toFixed(1)}Hz`;
const fmtAll = (bs) => bs.map(fmtB).join(' · ');

// (a) Figure 15 scale — note names → Hz, band edges = geometric mean of neighbours
{
const scale = [[440, 'u'], [659.3, 'o'], [880, 'ɔ'], [830.6, 'ɔ'], [1319, 'ɑ'],
[1760, 'a'], [2637, 'æ'], [4186, 'i'], [8000, 'iB']];
const wrong = scale.filter(([f, k]) => (DSP.astcOf(f) || {}).key !== k)
.map(([f, k]) => `${f}→${(DSP.astcOf(f) || {}).key}≠${k}`);
const line = `scale: ${scale.map(([f, k]) => `${f}→${k}`).join(', ')}`;
wrong.length === 0 ? pass(line) : fail(`${line} [${wrong.join(' ')}]`);

const edgeOk = DSP.astcOf(538.6).key === 'o' && DSP.astcOf(538.5).key === 'u' &&
DSP.astcIndex(440) === 0 && DSP.astcIndex(8000) === 7;
const guardOk = DSP.astcOf(0) === null && DSP.astcOf(-5) === null &&
DSP.astcOf(NaN) === null && DSP.astcIndex(0) === -1;
(edgeOk && guardOk) ? pass('band edges inclusive-low, and 0 / negative / NaN → null')
: fail(`edges ${edgeOk} guards ${guardOk}`);
}

// (b) Figure 17 — one bundle of H2,H3,H4 over f0=220; the percept follows the
// amplitude-weighted centroid as the balance inside the bundle shifts.
{
const tri = (d2, d3, d4) => DSP.spectralBundles(
[{ n: 2, freq: 440, db: d2 }, { n: 3, freq: 660, db: d3 }, { n: 4, freq: 880, db: d4 }],
{ f0: 220 });

let bs = tri(0, 0, 0);
let ok = bs.length === 1 && bs[0].label === '<o' && bs[0].complexity === 3 &&
!bs[0].weakBridge && Math.abs(bs[0].centroid - 660) < 0.5;
ok ? pass(`equal H2/H3/H4 → ${fmtAll(bs)} (expect <o, centroid 660)`)
: fail(`equal H2/H3/H4 → ${fmtAll(bs)} (expect <o, centroid 660)`);

// H2 louder → the colour darkens toward ~u. NOTE: with the specified linear
// amplitude weighting (a = 10^(dB/20)) a +12 dB boost lands at 550.3 Hz — a
// 110 Hz move toward ~u but still 12 Hz inside ~o (the ~u/~o edge is 538.6
// Hz; crossing it needs ≥ +13.4 dB). Both steps are gated.
bs = tri(12, 0, 0);
ok = bs.length === 1 && bs[0].complexity === 3 && !bs[0].weakBridge &&
bs[0].centroid < 600 && Math.abs(bs[0].centroid - 550.3) < 1;
ok ? pass(`H2 +12 dB → ${fmtAll(bs)} (centroid falls 660→550 toward ~u)`)
: fail(`H2 +12 dB → ${fmtAll(bs)} (expect centroid ≈550 Hz)`);

bs = tri(18, 0, 0);
ok = bs.length === 1 && bs[0].label === '<u' && bs[0].complexity === 3 && !bs[0].weakBridge;
ok ? pass(`H2 +18 dB → ${fmtAll(bs)} (expect <u)`) : fail(`H2 +18 dB → ${fmtAll(bs)} (expect <u)`);

bs = tri(0, 0, 12);
ok = bs.length === 1 && bs[0].label === '<ɔ' && bs[0].complexity === 3 && !bs[0].weakBridge;
ok ? pass(`H4 +12 dB → ${fmtAll(bs)} (expect <ɔ)`) : fail(`H4 +12 dB → ${fmtAll(bs)} (expect <ɔ)`);
}

// (c) Weak tone-color bridging — the centroid's band holds no actual harmonic
{
let bs = DSP.spectralBundles([{ n: 1, freq: 440, db: 0 }, { n: 2, freq: 880, db: 0 }], { f0: 440 });
let ok = bs.length === 1 && bs[0].label === '<o' && bs[0].weakBridge === true &&
Math.abs(bs[0].centroid - 660) < 0.5;
ok ? pass(`A4: H1+H2 equal → ${fmtAll(bs)} (expect <o weak-bridged)`)
: fail(`A4: H1+H2 equal → ${fmtAll(bs)} (expect <o(wb))`);

// A♭5: H2 is 30 dB down — a lone non-H1 harmonic never forms its own bundle,
// so it merges back and the pair reads as the ASTC of H1.
bs = DSP.spectralBundles([{ n: 1, freq: 830.6, db: 0 }, { n: 2, freq: 1661.2, db: -30 }], { f0: 830.6 });
ok = bs.length === 1 && bs[0].label === '<ɔ' && bs[0].weakBridge === false && bs[0].complexity === 2;
ok ? pass(`A♭5: H1 0dB + H2 −30dB → ${fmtAll(bs)} (expect <ɔ, not weak-bridged)`)
: fail(`A♭5: H1 0dB + H2 −30dB → ${fmtAll(bs)} (expect <ɔ)`);
}

// (d) Trough splitting — two peaks over f0=110 separated by a −20 dB valley at H9
{
const prof = { 1: -18, 2: -14, 3: -10, 4: -6, 5: 0, 6: 2, 7: 0, 8: -10, 9: -20,
10: -6, 11: -2, 12: -6, 13: -16, 14: -24 };
const hs = Object.keys(prof).map((n) => ({ n: +n, freq: 110 * +n, db: prof[n] }));
const bs = DSP.spectralBundles(hs, { f0: 110 });
const k1 = bs[0] && bs[0].astc ? bs[0].astc.key : '?';
const k2 = bs[1] && bs[1].astc ? bs[1].astc.key : '?';
const ok = bs.length >= 2 && (k1 === 'o' || k1 === 'ɔ') && (k2 === 'ɑ' || k2 === 'a');
const line = `f0=110 two-hump (H5–H7 / H10–H12, H9 −20 dB): ${bs.length} bundles → ${fmtAll(bs)}`;
ok ? pass(line) : fail(line + ' (expect ≥2, first o|ɔ, second ɑ|a)');
}

// (e) End-to-end: synthesized vowel → FFT → harmonic dB → bundles
{
const sr = 44100, f0 = 110, N = 8192;
const sig = DSP.synthVowel({ sr, dur: 0.5, f0, formants: [700, 1220, 2600, 3400, 4500] });
const w = DSP.hann(N);
const re = new Float64Array(N), im = new Float64Array(N);
const start = Math.round(0.25 * sr) - (N >> 1);
for (let i = 0; i < N; i++) {
const idx = start + i;
re[i] = (idx >= 0 && idx < sig.length ? sig[idx] : 0) * w[i];
}
DSP.fftRadix2(re, im);
const bins = N >> 1, binHz = sr / N;
const harmonics = [];
for (let n = 1; n * f0 <= 6000; n++) {
const lo = Math.max(0, Math.ceil((n * f0 * 0.9) / binHz));
const hi = Math.min(bins - 1, Math.floor((n * f0 * 1.1) / binHz));
let best = -Infinity, bf = n * f0;
for (let i = lo; i <= hi; i++) {
const db = 20 * Math.log10(Math.hypot(re[i], im[i]) + 1e-12);
if (db > best) { best = db; bf = i * binHz; }
}
if (isFinite(best)) harmonics.push({ n, freq: bf, db: best });
}
const bs = DSP.spectralBundles(harmonics, { f0 });
// The loudest bundle is the F1 peak (F1 = 700 Hz → H6/H7 region). NOTE: the
// literal FIRST bundle here is H1–H3 (~200 Hz, <u) — the true-fundamental
// bundle, which the 3 dB trough rule correctly separates from the F1 hump.
let loud = bs[0];
for (const b of bs) if (b.peakDb > loud.peakDb) loud = b;
const ok = bs.length >= 2 && loud.centroid > 500 && loud.centroid < 1000 &&
(loud.label === '<o' || loud.label === '<ɔ') &&
bs[0].isFundamental === true;
const line = `synthVowel f0=110 F1=700 → ${bs.length} bundles: ${fmtAll(bs)} | F1 bundle ${loud.label} c=${loud.centroid.toFixed(0)}Hz`;
ok ? pass(line) : fail(line + ' (expect ≥2 bundles, F1 bundle <o|<ɔ in 500–1000 Hz)');
}
}

// ---------------------------------------------------------------------------
console.log('');
if (failures === 0) { console.log('\x1b[32m\x1b[1mALL GATES PASSED\x1b[0m\n'); process.exit(0); }
Expand Down
190 changes: 190 additions & 0 deletions docs/dsp-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -1568,6 +1568,195 @@
return outF;
}

// ----------------------------------------------------------------------------
// ASTC — Absolute Spectral Tone Color (Howell 2016) + local spectral coherence
// ("bundle vowels").
//
// Ian Howell, "Parsing the Spectral Envelope: Toward a General Theory of
// Vocal Tone Color" (DMA thesis, New England Conservatory, 2016), Figure 15.
//
// A simple tone (a lone sine / a single harmonic) carries a vowel quality that
// depends ONLY on its frequency. ASTC_BANDS is Howell's Figure 15 scale, with
// each band edge taken as the geometric mean of the two adjacent note names.
// A spectral peak — a "bundle" of adjacent harmonics bounded on both sides by
// lower-amplitude harmonics — is heard as the ASTC of its amplitude-weighted
// mean frequency (its spectral centroid), written "<o", "<ɑ", …
// ----------------------------------------------------------------------------
const ASTC_BANDS = [
{ key: 'u', label: '~u', lo: 0, hi: 538.6, notes: '≤C5' },
{ key: 'o', label: '~o', lo: 538.6, hi: 761.7, notes: 'C♯5–F♯5' },
{ key: 'ɔ', label: '~ɔ', lo: 761.7, hi: 1141.2, notes: 'G5–C♯6' },
{ key: 'ɑ', label: '~ɑ', lo: 1141.2, hi: 1523.3, notes: 'D6–F♯6' },
{ key: 'a', label: '~a', lo: 1523.3, hi: 2282.4, notes: 'G6–C♯7' },
{ key: 'æ', label: '~æ/ɛ/e', lo: 2282.4, hi: 2875.7, notes: 'D7–F7' },
{ key: 'i', label: '~i', lo: 2875.7, hi: 6093.4, notes: 'G♭7–F♯8' },
{ key: 'iB', label: '~bright i', lo: 6093.4, hi: Infinity, notes: 'G8↑' },
];
for (let i = 0; i < ASTC_BANDS.length; i++) {
ASTC_BANDS[i].index = i;
Object.freeze(ASTC_BANDS[i]);
}
Object.freeze(ASTC_BANDS);

// 0..7, or -1 for a non-positive / non-finite frequency.
function astcIndex(freqHz) {
const f = +freqHz;
if (!(f > 0) || !isFinite(f)) return -1;
for (let i = 0; i < ASTC_BANDS.length; i++) {
if (f >= ASTC_BANDS[i].lo && f < ASTC_BANDS[i].hi) return i;
}
return -1;
}

// The (frozen) band object for this frequency, or null.
function astcOf(freqHz) {
const i = astcIndex(freqHz);
return i < 0 ? null : ASTC_BANDS[i];
}

// Bundle label: "~o" → "<o", "~æ/ɛ/e" → "<æ/ɛ/e", "~bright i" → "<bright i".
function astcBundleLabel(band) {
return band ? '<' + band.label.slice(1) : '';
}

// Glasberg & Moore (1990) ERB at centre frequency f (Hz). Mirrors main.js.
function erbHz(f) {
return 24.7 * (4.37 * f / 1000 + 1);
}

/*
* spectralBundles(harmonics, opts) — group measured harmonics into spectral
* peaks ("bundles") and give each one its perceived ASTC vowel.
*
* harmonics: [{ n, freq, db }] — db is any consistent relative dB scale;
* missing harmonics are simply absent (neighbours stay adjacent).
* opts: { f0, floorDb = 40, minTroughDb = 3, maxHarmonics = 40 }
*
* Returns bundles in ascending frequency order:
* { astc, label, centroid, weakBridge, complexity, rough, isFundamental,
* peakDb, peakN, peakFreq, nFrom, nTo, freqFrom, freqTo, harmonics }
*/
function spectralBundles(harmonics, opts) {
opts = opts || {};
const floorDb = opts.floorDb != null ? opts.floorDb : 40;
const minTroughDb = opts.minTroughDb != null ? opts.minTroughDb : 3;
const maxHarmonics = opts.maxHarmonics != null ? opts.maxHarmonics : 40;
const f0 = opts.f0 != null ? +opts.f0 : 0;
if (!harmonics || !harmonics.length) return [];

// (1) sanitize → ascending by frequency → cap count → drop the noise floor
let hs = [];
for (let i = 0; i < harmonics.length; i++) {
const h = harmonics[i];
if (!h) continue;
const freq = +h.freq, db = +h.db;
if (!isFinite(freq) || freq <= 0 || !isFinite(db)) continue;
hs.push({ n: h.n, freq, db });
}
if (!hs.length) return [];
hs.sort((a, b) => a.freq - b.freq);
if (hs.length > maxHarmonics) hs = hs.slice(0, maxHarmonics);
let maxDb = -Infinity;
for (let i = 0; i < hs.length; i++) if (hs[i].db > maxDb) maxDb = hs[i].db;
hs = hs.filter((h) => h.db >= maxDb - floorDb);
if (!hs.length) return [];

// (2) linear amplitude weights
for (let i = 0; i < hs.length; i++) hs[i].amp = Math.pow(10, hs[i].db / 20);

// (3) trough detection — a local minimum whose NEAREST local maxima on both
// sides stand at least minTroughDb above it splits the series. Shallow
// dips inside one broad peak do not split.
const N = hs.length;
const splitAfter = new Array(N).fill(false);
for (let k = 1; k < N - 1; k++) {
const v = hs[k].db;
if (!(v <= hs[k - 1].db && v <= hs[k + 1].db)) continue;
if (v === hs[k - 1].db && v === hs[k + 1].db) continue; // flat run, no trough
let left = -Infinity;
for (let i = k - 1; i >= 0; i--) {
if (hs[i].db >= left) left = hs[i].db; else break; // stop past the nearest peak
}
let right = -Infinity;
for (let i = k + 1; i < N; i++) {
if (hs[i].db >= right) right = hs[i].db; else break;
}
if (left >= v + minTroughDb && right >= v + minTroughDb) splitAfter[k] = true;
}

// Build the contiguous groups (the trough harmonic closes the lower bundle).
let groups = [];
let cur = [hs[0]];
for (let k = 1; k < N; k++) {
if (splitAfter[k - 1]) { groups.push(cur); cur = []; }
cur.push(hs[k]);
}
groups.push(cur);

// (4) a lone harmonic that is not H1 cannot be a bundle → merge it into the
// louder neighbour (tie → the upper one). H1 may stand alone (Howell's
// "obvious true fundamental").
const hasFundamental = (g) => g.some((h) => h.n === 1);
const groupPeakDb = (g) => g.reduce((m, h) => Math.max(m, h.db), -Infinity);
let merged = true;
while (merged && groups.length > 1) {
merged = false;
for (let gi = 0; gi < groups.length; gi++) {
if (groups[gi].length !== 1 || hasFundamental(groups[gi])) continue;
const lo = gi > 0 ? groups[gi - 1] : null;
const hi = gi < groups.length - 1 ? groups[gi + 1] : null;
let target = -1;
if (lo && hi) target = groupPeakDb(hi) >= groupPeakDb(lo) ? gi + 1 : gi - 1;
else if (hi) target = gi + 1;
else if (lo) target = gi - 1;
if (target < 0) continue;
const a = Math.min(gi, target), b = Math.max(gi, target);
groups[a] = groups[a].concat(groups[b]);
groups.splice(b, 1);
merged = true;
break;
}
}

// (5) describe every bundle
const out = [];
for (let gi = 0; gi < groups.length; gi++) {
const g = groups[gi];
let sw = 0, swf = 0, peakDb = -Infinity, peak = g[0];
for (let i = 0; i < g.length; i++) {
sw += g[i].amp;
swf += g[i].amp * g[i].freq;
if (g[i].db > peakDb) { peakDb = g[i].db; peak = g[i]; }
}
const centroid = sw > 0 ? swf / sw : g[0].freq;
const astc = astcOf(centroid);
let inBand = 0, rough = 0;
for (let i = 0; i < g.length; i++) {
if (astc && g[i].freq >= astc.lo && g[i].freq < astc.hi) inBand++;
if (f0 > 0 && f0 < erbHz(g[i].freq)) rough++;
}
out.push({
astc,
label: astcBundleLabel(astc),
centroid,
weakBridge: inBand === 0,
complexity: g.length,
rough,
isFundamental: hasFundamental(g),
peakDb,
peakN: peak.n,
peakFreq: peak.freq,
nFrom: g[0].n,
nTo: g[g.length - 1].n,
freqFrom: g[0].freq,
freqTo: g[g.length - 1].freq,
harmonics: g.map((h) => ({ n: h.n, freq: h.freq, db: h.db })),
});
}
out.sort((a, b) => a.freqFrom - b.freqFrom);
return out;
}

const api = {
fftRadix2, nextPow2, hann,
burgLPC, durandKerner, lpcFormants, decimate,
Expand All @@ -1578,6 +1767,7 @@
envelopeBeat,
analyzeVibrato,
synthVowel,
ASTC_BANDS, astcOf, astcIndex, astcBundleLabel, erbHz, spectralBundles,
};

if (typeof module !== 'undefined' && module.exports) {
Expand Down
28 changes: 28 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,10 @@ <h2 title="Source × Filter = Output">Power Spectrum</h2>
<button id="roughness-toggle" class="inline-toggle-btn" type="button"
title="倍音のラフネス・ゾーンを表示">Roughness</button>
</div>
<div class="canvas-control-group" title="Howell の Absolute Spectral Tone Color 帯と、倍音の束ごとの母音(&lt;o など)を表示">
<button id="astc-toggle" class="inline-toggle-btn" type="button"
title="Howell の Absolute Spectral Tone Color 帯と、倍音の束ごとの母音(&lt;o など)を表示">ASTC</button>
</div>
<div class="canvas-control-buttons mic-group">
<div class="mic-controls">
<div class="canvas-control-group" title="マイク入力の感度">
Expand Down Expand Up @@ -1085,6 +1089,30 @@ <h4 class="rl-deep-h">ERB = Equivalent Rectangular Bandwidth</h4>
</details>
</div>

<!-- ASTC educational legend (visible when the ASTC toggle is ON) -->
<div id="astc-legend" class="astc-legend" style="display:none;">
<div class="al-header">
<span class="al-title">ASTC — Absolute Spectral Tone Color</span>
<span class="al-src">出典: Howell (2016) Figure 15 / 局所スペクトル・コヒーレンス</span>
</div>
<div class="al-bands">
<span class="al-band" data-astc="u"><i class="al-chip"></i>~u <em>≤C5 (&lt;539Hz)</em></span>
<span class="al-band" data-astc="o"><i class="al-chip"></i>~o <em>C♯5–F♯5 (539–762Hz)</em></span>
<span class="al-band" data-astc="open-o"><i class="al-chip"></i>~ɔ <em>G5–C♯6 (762–1141Hz)</em></span>
<span class="al-band" data-astc="script-a"><i class="al-chip"></i>~ɑ <em>D6–F♯6 (1141–1523Hz)</em></span>
<span class="al-band" data-astc="a"><i class="al-chip"></i>~a <em>G6–C♯7 (1523–2282Hz)</em></span>
<span class="al-band" data-astc="ae"><i class="al-chip"></i>~æ/ɛ/e <em>D7–F7 (2282–2876Hz)</em></span>
<span class="al-band" data-astc="i"><i class="al-chip"></i>~i <em>G♭7–F♯8 (2876–6093Hz)</em></span>
<span class="al-band" data-astc="iB"><i class="al-chip"></i>~bright i <em>G8↑ (≥6093Hz)</em></span>
</div>
<div class="al-readout-row">
<span class="al-readout-label">束の母音 (&lt; percept)</span>
<span class="al-readout" id="astc-bundle-readout">—</span>
</div>
<div class="al-note">単純音の周波数だけで決まる母音らしさ(ASTC)。倍音の束=スペクトルピークは、その振幅重心の ASTC の母音(&lt;o など)として聞こえる。</div>
<div class="al-note">(wb)=束の重心の帯に倍音が 1 本も無い=弱く架橋された(weak bridging)色。×N は束に含まれる倍音の本数。</div>
</div>

</section>
</main>
</div>
Expand Down
Loading
Loading