All files / src audio-output.js

97.87% Statements 46/47
90.47% Branches 19/21
100% Functions 17/17
97.36% Lines 37/38

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                    6x 6x         6x       6x                     6x 6x   6x         6x     128x     6x 15x 15x 30x 15x     2400x     30000x     6x 6x 6x 30x 30x         75x     45x   15x           15x                         75x 6000x 15x             50x 50x 43x                         58x 50x   150x   50x 15x 58x    
// The stages between the sound chip and the device, shared by the worklet and
// by tools that replay the same path headlessly. Values and their sources are
// in docs/audio-path.md.
 
import { Biquad } from "./biquad.js";
 
// The board's output filter is an equal-component Sallen-Key (Service Manual
// section 3.8: 10K and 2n2 twice, gain K = 1 + 22/39): f0 = 1/(2*pi*RC) = 7234 Hz,
// Q = 1/(3 - K) = 0.696, below 1/sqrt(2), so no resonant peak. Run at the chip
// rate, ahead of the resampler.
export const OutputFilterHz = 7234;
export const OutputFilterQ = 0.696;
 
// The coupling capacitors after the filter, each a first-order high-pass:
// C10 100nF into the LM386's 50K input, C79 330nF into 4K7 + 1K (Master only),
// and C18 47uF into the 8 ohm speaker.
export const BoardHighPassHz = [32, 85, 423];
 
// The internal speaker and case, fitted to microphone captures of a Master 128
// (the driver's resonance, a presence bump, and the roll-off above 6 kHz).
export const SpeakerStages = [
    { type: "highPass", frequency: 550, q: 1.56 },
    { type: "peaking", frequency: 460, q: 3.5, gainDb: 16.5 },
    { type: "peaking", frequency: 2750, q: 0.5, gainDb: 13.9 },
    { type: "highShelf", frequency: 5600, q: 1.3, gainDb: -16.7 },
    { type: "lowPass", frequency: 11700, q: 1.7 },
];
 
// The resampler's sinc is cut off below the output Nyquist so that its
// transition band has finished before anything folds; sampled sound rides on
// a 31 kHz or higher carrier that would otherwise land in the audible band.
export const ResamplerCutoffOfOutputRate = 0.4;
export const ResamplerTaps = 201;
 
export const AudioOutputs = Object.freeze({
    speaker: "speaker",
    board: "board",
    off: "off",
});
export const DefaultAudioOutput = AudioOutputs.speaker;
 
export function isAudioOutput(value) {
    return Object.values(AudioOutputs).includes(value);
}
 
const stageFactories = {
    lowPass: (rate, s) => Biquad.lowPass(rate, s.frequency, s.q),
    highPass: (rate, s) => Biquad.highPass(rate, s.frequency, s.q),
    peaking: (rate, s) => Biquad.peaking(rate, s.frequency, s.q, s.gainDb),
    highShelf: (rate, s) => Biquad.highShelf(rate, s.frequency, s.q, s.gainDb),
};
 
const GainProbeHz = [...Array(400).keys()].map((i) => 20 * 2 ** (i / 40));
 
function chainMagnitude(stages, sampleRate, hz) {
    return stages.reduce((gain, stage) => gain * stage.magnitudeAt(sampleRate, hz), 1);
}
 
const FlatQ = Math.SQRT1_2;
const FlatHighPassHz = 20;
const FlatLowPassHz = 20000;
const lerp = (from, to, t) => from + (to - from) * t;
const lerpLog = (from, to, t) => from * (to / from) ** t;
 
// The fit scaled toward flat: gains shrink, the resonant Q falls to
// Butterworth, and the band edges move out of the way.
function scaledStage(stage, amount) {
    switch (stage.type) {
        case "peaking":
        case "highShelf":
            return { ...stage, gainDb: stage.gainDb * amount };
        case "highPass":
            return {
                ...stage,
                frequency: lerpLog(FlatHighPassHz, stage.frequency, amount),
                q: lerp(FlatQ, stage.q, amount),
            };
        case "lowPass":
            return {
                ...stage,
                frequency: lerpLog(FlatLowPassHz, stage.frequency, amount),
                q: lerp(FlatQ, stage.q, amount),
            };
        default:
            throw new Error(`Unknown stage type ${stage.type}`);
    }
}
 
// The speaker's presence bump would push loud chords past full scale, so the
// chain is scaled to peak at unity across the band.
function speakerStages(sampleRate, amount) {
    const stages = SpeakerStages.map((stage) => stageFactories[stage.type](sampleRate, scaledStage(stage, amount)));
    const peak = Math.max(...GainProbeHz.map((hz) => chainMagnitude(stages, sampleRate, hz)));
    return [...stages, Biquad.gain(1 / peak)];
}
 
// Zero turns the whole path off; a setting the biquad cannot realise
// (non-finite, at or above Nyquist, non-positive Q) falls back to the board's
// own values.
function boardFilter(sampleRate, frequency = OutputFilterHz, q = OutputFilterQ) {
    const usable = frequency < sampleRate / 2 && q > 0;
    if (!usable) return Biquad.lowPass(sampleRate, OutputFilterHz, OutputFilterQ);
    return Biquad.lowPass(sampleRate, frequency, q);
}
 
/**
 * The filters to run on the chip's output, in order, for the chosen output.
 *
 * @param {number} sampleRate the chip's rate
 * @param {string} output one of AudioOutputs
 * @param {{filterHz?: number, filterQ?: number, speakerAmount?: number}} options overrides for the
 *     board's low-pass, and how much of the speaker fit to apply (1 is the fit, 0 is flat)
 * @returns {Biquad[]}
 */
export function outputStages(sampleRate, output, { filterHz, filterQ, speakerAmount = 1 } = {}) {
    if (output === AudioOutputs.off || filterHz <= 0) return [];
    const board = [
        boardFilter(sampleRate, filterHz, filterQ),
        ...BoardHighPassHz.map((hz) => Biquad.firstOrderHighPass(sampleRate, hz)),
    ];
    if (output === AudioOutputs.board) return board;
    const amount = Number.isFinite(speakerAmount) ? Math.min(Math.max(speakerAmount, 0), 1) : 1;
    return [...board, ...speakerStages(sampleRate, amount)];
}