Headphones crossfeed plugin, Jan Meier style
This helps with listening to records from the '60s and '70s on headphones (Beatles, Pink Floyd etc.), see the docs included in the script for usage. It uses mid/side encoding, a short delay and two RC filters. The CPU usage stays below 5% on my notebook (and will get even lower once this PR is merged).
Here’s a nice block diagram:
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ┌────────────┐ ┌────────────┐ │
│ ────>│ ├─────────────────────────────>│ ├───> │
│ L │ Mid/side │ M ┌────────┐ M │ Mid/side │ L │
│ R │ encoder │ S │ Hipass │ g S │ decoder │ R │
│ ────>│ ├───>│ 100 Hz ├──┬─────>+─────>│ ├───> │
│ └────────────┘ │ 1 pole │ │ A └────────────┘ │
│ a └────────┘ │ │ h │
│ b │ │ │
│ │ │ │
│ ┌────────┐ │ ┌─────┐ │
│ │ Delay │<─┘ │ vol │ │
│ │ 300 uS │ └─────┘ │
│ └────────┘ f A │
│ c │ │ │
│ V │ │
│ ┌────────┐ │ │
│ │ Lopass │ ┌─────┐ │
│ │ 2 KHz ├─────>│ inv │ │
│ │ 1 pole │ └─────┘ │
│ └────────┘ e │
│ d │
│ │
└───────────────────────────────────────────────────────────────────────┘
And here’s the script (first one in Lua!). EDIT: updated to v. 1.1:
{
"id": 122,
"plugin": "VCV-Prototype",
"version": "1.2.0",
"model": "Prototype",
"data": {
"path": "/home/nl/devel/mine/vcvrack/prototype/crossfeed.lua",
"script": "-- # Headphones crossfeed\n--\n-- v. 1.1 - 20200524\n-- Nicola \"teknico\" Larosa <vcvrack@teknico.net>\n--\n-- Better headphones listening of hard-panned stereo music ('60s, '70s).\n--\n-- See http://www.meier-audio.homepage.t-online.de/crossfeed.htm\n--\n-- ## Usage\n--\n-- IN1 and OUT1 are left channels, IN2 and OUT2 are right channels.\n--\n-- * K1 knob: crossfeed amount (default: equal)\n-- * K2 knob: stereo width (default: normal)\n-- * S1 switch: complete bypass (default: off)\n-- * S2 switch: disable mono lows (default: off)\n--\n-- ## Algorithm\n--\n-- The stereo input is encoded to mid and side signals. The mid signal is sent\n-- as-is to the mid/side decoder. The side signal is subject to two changes:\n--\n-- * low frequencies are removed, resulting in them being mono in the output,\n-- in keeping with the human inability to detect their origin;\n-- * the remaining side signal is delayed according to the distance between\n-- ears, some high frequencies are removed to simulate the human head\n-- filtering effect, the signal is inverted and the result is summed back to\n-- the original side signal, resulting in cross components being added to\n-- each channel, as described in the link at the top.\n--\n-- The mid signal is left alone to minimize coloration due to comb filtering,\n-- and because the mono mid signal does not need additional cross components.\n--\n-- For mid/side encoding and decoding see:\n-- https://en.wikipedia.org/wiki/Joint_encoding#M/S_stereo_coding and\n-- https://en.wikipedia.org/wiki/Stereophonic_sound#M%2FS_technique%3A_mid%2Fside_stereophony\n--\n--\n-- ┌───────────────────────────────────────────────────────────────────────┐\n-- │ │\n-- │ ┌────────────┐ ┌────────────┐ │\n-- │ ────>│ ├─────────────────────────────>│ ├───> │\n-- │ L │ Mid/side │ M ┌────────┐ M │ Mid/side │ L │\n-- │ R │ encoder │ S │ Hipass │ g S │ decoder │ R │\n-- │ ────>│ ├───>│ 100 Hz ├──┬─────>+─────>│ ├───> │\n-- │ └────────────┘ │ 1 pole │ │ A └────────────┘ │\n-- │ a └────────┘ │ │ h │\n-- │ b │ │ │\n-- │ │ │ │\n-- │ ┌────────┐ │ ┌─────┐ │\n-- │ │ Delay │<─┘ │ vol │ │\n-- │ │ 300 uS │ └─────┘ │\n-- │ └────────┘ f A │\n-- │ c │ │ │\n-- │ V │ │\n-- │ ┌────────┐ │ │\n-- │ │ Lopass │ ┌─────┐ │\n-- │ │ 2 KHz ├─────>│ inv │ │\n-- │ │ 1 pole │ └─────┘ │\n-- │ └────────┘ e │\n-- │ d │\n-- │ │\n-- └───────────────────────────────────────────────────────────────────────┘\n\n\n-- Delay class.\n\nlocal Delay = {}\nDelay.__index = Delay\n\nsetmetatable(Delay, {\n __call = function (cls, ...)\n return cls.new(...)\n end,\n})\n\nfunction Delay.new(size)\n local self = setmetatable({}, Delay)\n self.size = size\n self.buffer = {}\n -- Init the ring buffer so that values may be used right away.\n for i=1,size do self.buffer[i] = 0 end\n self.cur = 1\n return self\nend\n\nfunction Delay.next(self, val)\n local ret = self.buffer[self.cur]\n self.buffer[self.cur] = val\n -- No brackets needed with 1-based indexing.\n self.cur = self.cur % self.size + 1\n return ret\nend\n\n\n-- RCFilter class, based on include/dsp/filter.hpp in VCV Rack.\n\nlocal RCFilter = {}\nRCFilter.__index = RCFilter\n\nsetmetatable(RCFilter, {\n __call = function (cls, ...)\n return cls.new(...)\n end,\n})\n\nfunction RCFilter.new(freq, fs)\n local self = setmetatable({}, RCFilter)\n local cutoff = fs / (math.pi * freq)\n self.minus = 1.0 - cutoff\n self.plus = 1.0 + cutoff\n self.x = 0\n self.y = 0\n return self\nend\n\nfunction RCFilter.next(self, x)\n -- Return:\n -- lowpass, hipass\n local y = (x + self.x - self.y * self.minus) / self.plus\n self.x = x\n self.y = y\n return y, (x - y)\nend\n\nfunction calc_output(left_input, right_input, ctx, params)\n -- Args:\n -- left_input, right_input: left and right current values\n -- ctx: see the make_context function\n -- params: see the handle_ui function\n --\n -- Return:\n -- left_output, right_output\n\n -- Bypass (not shown on diagram.)\n if params.bypass then\n return left_input, right_input\n end\n\n -- (a) compute mid and side signals, keeping the overall level constant.\n local mid = (left_input + right_input) / 2.0\n local side = (left_input - right_input) / 2.0\n\n -- (b) hipass-filter the side signal if not disabled.\n local hi_side\n if params.no_mono_lows then\n hi_side = side\n else\n _, hi_side = ctx.HIPASS:next(side)\n end\n\n -- (c) delay the signal from (b).\n local other_side\n other_side = ctx.DELAY:next(hi_side)\n\n -- (d) lowpass-filter the signal from (c).\n other_side, _ = ctx.LOWPASS:next(other_side)\n\n -- (e, f) invert and attenuate the signal from (d).\n other_side = -other_side * params.xfeed_amount\n\n -- (g) sum the signal from (b) and the one from (f).\n local out_side\n out_side = hi_side + other_side\n\n -- Apply the width control (not shown on the diagram).\n out_side = out_side * params.width\n\n -- (h) mid/side decode the mid signal and the one from above.\n return mid + out_side, mid - out_side\nend\n\nfunction handle_ui(block, ctx)\n -- Get knobs and switches values, turn on and off lights.\n --\n -- Args:\n -- block: the VCV Rack Prototype environment\n -- ctx: see the make_context function\n --\n -- Return:\n -- params:\n -- crossfeed amount (left knob), 0..2\n -- stereo width (right knob), 0..2\n -- complete bypass (left switch), boolean\n -- no mono lows (right switch), boolean\n\n -- Bypass.\n if block.switches[ctx.LEFT_IDX] then\n -- Green switch light off.\n block.switchLights[ctx.LEFT_IDX][2] = 0\n display(\"BYPASS\")\n return {\n xfeed_amount = 0,\n width = 0,\n bypass = true,\n no_mono_lows = false,\n }\n end\n\n local params = {}\n\n -- No bypass, green switch light on.\n block.switchLights[ctx.LEFT_IDX][2] = 1\n params.xfeed_amount = block.knobs[ctx.LEFT_IDX] * 2\n params.width = block.knobs[ctx.RIGHT_IDX] * 2\n local msg = string.format(\n \"xfeed %.2f, width %.2f\", params.xfeed_amount, params.width)\n\n params.no_mono_lows = block.switches[ctx.RIGHT_IDX]\n if params.no_mono_lows then\n -- Blue switch light off.\n block.switchLights[ctx.RIGHT_IDX][3] = 0\n else\n -- Mono lows, blue switch light on.\n block.switchLights[ctx.RIGHT_IDX][3] = 1\n msg = string.format(\"%s, mono lows\", msg)\n end\n display(msg)\n\n params.bypass = false\n return params\nend\n\nfunction make_context(block)\n -- Make a context table with the fields:\n --\n -- LEFT_IDX, RIGHT_IDX: indexes of the left and right UI columns\n -- FS: current sampling frequency\n -- DELAY: short delay unit\n -- HIPASS, LOWPASS: hipass and lowpass filters, initialized with the\n -- current sampling frequency\n\n local ctx = {\n LEFT_IDX = 1,\n RIGHT_IDX = 2,\n FS = block.sampleRate,\n -- Approx. 300 uS.\n DELAY = Delay(16),\n HIPASS = RCFilter(100.0, block.sampleRate),\n LOWPASS = RCFilter(2000.0, block.sampleRate),\n }\n return ctx\nend\n\n\n-- VCV Rack config parameters.\n\nconfig.frameDivider = 1\nconfig.bufferSize = 16\n\n\n-- Common context, see the make_context function.\nlocal ctx = nil\n\nfunction process(block)\n if not ctx then\n ctx = make_context(block)\n end\n\n -- The user interface doesn't need to be handled at audio rate...\n local params = handle_ui(block, ctx)\n\n -- ...the audio clearly does.\n for j=1,block.bufferSize do\n\n -- Get the left and right inputs.\n local left_input = block.inputs[ctx.LEFT_IDX][j]\n local right_input = block.inputs[ctx.RIGHT_IDX][j]\n\n -- Compute the left and right outputs.\n left_output, right_output = calc_output(\n left_input, right_input, ctx, params)\n\n -- Set the left and right outputs.\n block.outputs[ctx.LEFT_IDX][j] = left_output\n block.outputs[ctx.RIGHT_IDX][j] = right_output\n end\nend\n"
}
}
Enjoy!