feat: add Lathe harmonic EQ and Temper tracked distortion
Check and Windows preview / check (anvil) (push) Successful in 2m8s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 2m13s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m34s
Check and Windows preview / check (temper) (push) Successful in 2m12s
Check and Windows preview / previews (push) Successful in 1m19s
Check and Windows preview / windows (push) Successful in 2m13s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 6s
Independent plugin release / build (push) Successful in 2m57s
Check and Windows preview / check (anvil) (push) Successful in 2m8s
Check and Windows preview / check (crucible) (push) Successful in 1m32s
Check and Windows preview / check (lathe) (push) Successful in 2m13s
Check and Windows preview / check (patina) (push) Successful in 2m5s
Check and Windows preview / check (rivet) (push) Successful in 1m34s
Check and Windows preview / check (temper) (push) Successful in 2m12s
Check and Windows preview / previews (push) Successful in 1m19s
Check and Windows preview / windows (push) Successful in 2m13s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 6s
Independent plugin release / build (push) Successful in 2m57s
Lathe places EQ bands on harmonics 1-32 of the played note, so a cut on harmonic 3 stays on harmonic 3 on every note. Temper normalises each note's level before an oversampled shaper and tunes its floor, push and bright filters to the note, so every note distorts the same. The note tracker moves from Anvil into common-dsp, together with a state-variable filter, 4x oversampler and harmonic meter. Anvil's behaviour and editor render are unchanged. docs/ideas.md collects all plugin ideas and their status.
This commit is contained in:
1 parent
f36ff0780c
commit
b3ebe4d952
48 files changed
+9862
-339
No files matched your search
+90
-322
@@ -5,27 +5,18 @@
|
||||
//! a note's pitch is known before its delayed attack leaves the plugin, and the sine starts
|
||||
//! exactly on that attack.
|
||||
mod settings;
|
||||
use common_dsp::{
|
||||
coefficient, db_to_gain, gain_to_db, hz_to_midi, midi_to_hz, Biquad, Crossover, Yin,
|
||||
};
|
||||
use common_dsp::tracker::{NoteEvent, Tracker, TrackerConfig};
|
||||
use common_dsp::{coefficient, db_to_gain, hz_to_midi, midi_to_hz, Crossover};
|
||||
pub use settings::{Settings, Source};
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
/// Lowest detectable fundamental. Sets the lookahead (two periods).
|
||||
pub const MIN_HZ: f32 = 25.;
|
||||
pub const MAX_HZ: f32 = 250.;
|
||||
/// Detectable fundamental range. The lowest sets the lookahead (two periods).
|
||||
pub const MIN_HZ: f32 = TrackerConfig::SUB.min_hz;
|
||||
pub const MAX_HZ: f32 = TrackerConfig::SUB.max_hz;
|
||||
pub const SCOPE: usize = 256;
|
||||
const EVENTS: usize = 64;
|
||||
const DETECT_RATE: f32 = 3000.;
|
||||
const HOP_MS: f32 = 5.;
|
||||
const ATTACK_MS: f32 = 2.;
|
||||
const RELEASE_MS: f32 = 15.;
|
||||
const CROSSFADE_MS: f32 = 3.;
|
||||
const GATE_HOLD_MS: f32 = 20.;
|
||||
const GATE_HYSTERESIS_DB: f32 = 6.;
|
||||
/// A pitch move this large (semitones) counts as a new note.
|
||||
const JUMP: f32 = 0.7;
|
||||
const CLARITY: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Monitor {
|
||||
@@ -65,19 +56,6 @@ impl Default for AnalyzerFrame {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Onset,
|
||||
Release,
|
||||
NoteOn(u8),
|
||||
NoteOff(u8),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Event {
|
||||
time: u64,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Voice {
|
||||
phase: f32,
|
||||
@@ -87,6 +65,55 @@ struct Voice {
|
||||
releasing: bool,
|
||||
}
|
||||
|
||||
/// Sine voices and per-note state, driven by tracker events at output time.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Synth {
|
||||
voice: Voice,
|
||||
old: Voice,
|
||||
old_gain: f32,
|
||||
note_peak: f32,
|
||||
}
|
||||
impl Synth {
|
||||
fn event(&mut self, s: &Settings, e: NoteEvent) {
|
||||
match e {
|
||||
NoteEvent::Start { pitch, glide } => self.start(s, pitch, glide),
|
||||
NoteEvent::Move { pitch } => {
|
||||
if !s.snap {
|
||||
self.voice.target = midi_to_hz(pitch + 12. * s.octave as f32);
|
||||
}
|
||||
}
|
||||
NoteEvent::Stop => self.voice.releasing = true,
|
||||
}
|
||||
}
|
||||
fn start(&mut self, s: &Settings, pitch: f32, glide: bool) {
|
||||
let note = if s.snap { pitch.round() } else { pitch };
|
||||
let hz = midi_to_hz(note + 12. * s.octave as f32);
|
||||
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
|
||||
if glide && audible {
|
||||
self.voice.target = hz;
|
||||
return;
|
||||
}
|
||||
if s.retrigger || !audible {
|
||||
if self.voice.amp > 1e-4 {
|
||||
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
|
||||
self.old = self.voice;
|
||||
self.old_gain = 1.;
|
||||
}
|
||||
self.voice = Voice {
|
||||
phase: s.phase / 360.,
|
||||
hz,
|
||||
target: hz,
|
||||
amp: 0.,
|
||||
releasing: false,
|
||||
};
|
||||
} else {
|
||||
self.voice.target = hz;
|
||||
self.voice.hz = hz;
|
||||
}
|
||||
self.note_peak = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Engine {
|
||||
rate: f32,
|
||||
channels: usize,
|
||||
@@ -95,37 +122,10 @@ pub struct Engine {
|
||||
delay: Vec<[f32; 2]>,
|
||||
delay_at: usize,
|
||||
crossover: [Crossover; 2],
|
||||
detect: [Biquad; 2],
|
||||
decimate: usize,
|
||||
decimate_count: usize,
|
||||
detect_rate: f32,
|
||||
ring: Vec<f32>,
|
||||
ring_at: usize,
|
||||
scratch: Vec<f32>,
|
||||
yin: Yin,
|
||||
hop: usize,
|
||||
hop_count: usize,
|
||||
gate_env: f32,
|
||||
gate_release: f32,
|
||||
gate_open: bool,
|
||||
gate_hold: usize,
|
||||
clock: u64,
|
||||
events: [Event; EVENTS],
|
||||
event_head: usize,
|
||||
event_len: usize,
|
||||
/// Output-time note state.
|
||||
sounding: bool,
|
||||
pending: bool,
|
||||
tracked: f32,
|
||||
candidate: Option<(f32, u8)>,
|
||||
voice: Voice,
|
||||
old: Voice,
|
||||
old_gain: f32,
|
||||
tracker: Tracker,
|
||||
synth: Synth,
|
||||
glide: f32,
|
||||
held: [u8; 16],
|
||||
held_len: usize,
|
||||
low_env: f32,
|
||||
note_peak: f32,
|
||||
env_coef: f32,
|
||||
smooth: f32,
|
||||
level: f32,
|
||||
@@ -150,12 +150,8 @@ impl Engine {
|
||||
if !(1..=2).contains(&channels) {
|
||||
return Err("unsupported channel count");
|
||||
}
|
||||
let decimate = (rate / DETECT_RATE).round().max(1.) as usize;
|
||||
let detect_rate = rate / decimate as f32;
|
||||
let max_lag = (detect_rate / MIN_HZ).ceil() as usize;
|
||||
// YIN needs one comparison window of at least the longest period after the lags.
|
||||
let analysis = 2 * max_lag + 3;
|
||||
let latency = analysis * decimate;
|
||||
let tracker = Tracker::new(rate, TrackerConfig::SUB);
|
||||
let latency = tracker.latency();
|
||||
let settings = Settings::default();
|
||||
let mut e = Self {
|
||||
rate,
|
||||
@@ -165,39 +161,10 @@ impl Engine {
|
||||
delay: vec![[0.; 2]; latency + 1],
|
||||
delay_at: 0,
|
||||
crossover: [Crossover::new(rate, settings.split); 2],
|
||||
detect: [Biquad::lowpass(rate, 400., std::f32::consts::FRAC_1_SQRT_2); 2],
|
||||
decimate,
|
||||
decimate_count: 0,
|
||||
detect_rate,
|
||||
ring: vec![0.; analysis],
|
||||
ring_at: 0,
|
||||
scratch: vec![0.; analysis],
|
||||
yin: Yin::new(max_lag + 2),
|
||||
hop: ((detect_rate * HOP_MS * 0.001) as usize).max(1),
|
||||
hop_count: 0,
|
||||
gate_env: 0.,
|
||||
gate_release: 1. - coefficient(rate, 30.),
|
||||
gate_open: false,
|
||||
gate_hold: 0,
|
||||
clock: 0,
|
||||
events: [Event {
|
||||
time: 0,
|
||||
kind: Kind::Release,
|
||||
}; EVENTS],
|
||||
event_head: 0,
|
||||
event_len: 0,
|
||||
sounding: false,
|
||||
pending: false,
|
||||
tracked: 0.,
|
||||
candidate: None,
|
||||
voice: Voice::default(),
|
||||
old: Voice::default(),
|
||||
old_gain: 0.,
|
||||
tracker,
|
||||
synth: Synth::default(),
|
||||
glide: 1.,
|
||||
held: [0; 16],
|
||||
held_len: 0,
|
||||
low_env: 0.,
|
||||
note_peak: 0.,
|
||||
env_coef: coefficient(rate, 10.),
|
||||
smooth: coefficient(rate, 10.),
|
||||
level: db_to_gain(settings.level),
|
||||
@@ -233,8 +200,11 @@ impl Engine {
|
||||
x.retune(self.rate, s.split);
|
||||
}
|
||||
}
|
||||
if s.source != old.source {
|
||||
self.release_all();
|
||||
if self
|
||||
.tracker
|
||||
.set(s.source == Source::Midi, s.gate, s.glide > 0.)
|
||||
{
|
||||
self.synth.voice.releasing = true;
|
||||
}
|
||||
self.glide = if s.glide <= 0. {
|
||||
1.
|
||||
@@ -244,34 +214,14 @@ impl Engine {
|
||||
self.settings = s;
|
||||
Ok(())
|
||||
}
|
||||
fn release_all(&mut self) {
|
||||
self.voice.releasing = true;
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.held_len = 0;
|
||||
self.event_len = 0;
|
||||
self.candidate = None;
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.delay.fill([0.; 2]);
|
||||
for x in &mut self.crossover {
|
||||
x.reset();
|
||||
}
|
||||
for f in &mut self.detect {
|
||||
f.reset();
|
||||
}
|
||||
self.ring.fill(0.);
|
||||
self.decimate_count = 0;
|
||||
self.hop_count = 0;
|
||||
self.gate_env = 0.;
|
||||
self.gate_open = false;
|
||||
self.gate_hold = 0;
|
||||
self.release_all();
|
||||
self.voice = Voice::default();
|
||||
self.old = Voice::default();
|
||||
self.old_gain = 0.;
|
||||
self.tracker.reset();
|
||||
self.synth = Synth::default();
|
||||
self.low_env = 0.;
|
||||
self.note_peak = 0.;
|
||||
self.low_power = 0.;
|
||||
self.sub_power = 0.;
|
||||
self.scope_low = [0.; 2 * SCOPE];
|
||||
@@ -279,190 +229,10 @@ impl Engine {
|
||||
}
|
||||
/// MIDI note start at the current input sample. Used when the source is MIDI.
|
||||
pub fn note_on(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOn(note.min(127)));
|
||||
self.tracker.note_on(note);
|
||||
}
|
||||
pub fn note_off(&mut self, note: u8) {
|
||||
self.push(Kind::NoteOff(note.min(127)));
|
||||
}
|
||||
fn push(&mut self, kind: Kind) {
|
||||
if self.event_len == EVENTS {
|
||||
// Dropping the oldest keeps the newest note state; overflow only happens on floods.
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
}
|
||||
let at = (self.event_head + self.event_len) % EVENTS;
|
||||
self.events[at] = Event {
|
||||
time: self.clock,
|
||||
kind,
|
||||
};
|
||||
self.event_len += 1;
|
||||
}
|
||||
/// Detected pitch of the most recent analysis window, in fractional MIDI notes.
|
||||
fn estimate(&mut self) -> Option<f32> {
|
||||
let n = self.ring.len();
|
||||
for i in 0..n {
|
||||
self.scratch[i] = self.ring[(self.ring_at + i) % n];
|
||||
}
|
||||
let (hz, clarity) = self
|
||||
.yin
|
||||
.estimate(&self.scratch, self.detect_rate, MIN_HZ, MAX_HZ)?;
|
||||
(clarity >= CLARITY).then(|| hz_to_midi(hz))
|
||||
}
|
||||
fn start(&mut self, pitch: f32, glide: bool) {
|
||||
let s = self.settings;
|
||||
let note = if s.snap { pitch.round() } else { pitch };
|
||||
let hz = midi_to_hz(note + 12. * s.octave as f32);
|
||||
self.tracked = pitch;
|
||||
self.candidate = None;
|
||||
self.pending = false;
|
||||
self.sounding = true;
|
||||
let audible = self.voice.amp > 1e-4 && !self.voice.releasing;
|
||||
if glide && audible {
|
||||
self.voice.target = hz;
|
||||
return;
|
||||
}
|
||||
if s.retrigger || !audible {
|
||||
if self.voice.amp > 1e-4 {
|
||||
// Restarting the phase mid-cycle would click; the old cycle fades out instead.
|
||||
self.old = self.voice;
|
||||
self.old_gain = 1.;
|
||||
}
|
||||
self.voice = Voice {
|
||||
phase: s.phase / 360.,
|
||||
hz,
|
||||
target: hz,
|
||||
amp: 0.,
|
||||
releasing: false,
|
||||
};
|
||||
} else {
|
||||
self.voice.target = hz;
|
||||
self.voice.hz = hz;
|
||||
}
|
||||
self.note_peak = 0.;
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
self.voice.releasing = true;
|
||||
self.sounding = false;
|
||||
self.pending = false;
|
||||
self.candidate = None;
|
||||
}
|
||||
fn handle(&mut self, kind: Kind) {
|
||||
let midi = self.settings.source == Source::Midi;
|
||||
match kind {
|
||||
Kind::Onset if !midi => match self.estimate() {
|
||||
Some(p) => self.start(p, false),
|
||||
None => {
|
||||
self.pending = true;
|
||||
self.sounding = true;
|
||||
}
|
||||
},
|
||||
Kind::Release if !midi => self.stop(),
|
||||
Kind::NoteOn(n) if midi => {
|
||||
let len = self.held_len;
|
||||
if let Some(i) = self.held[..len].iter().position(|h| *h == n) {
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
if self.held_len == self.held.len() {
|
||||
self.held.copy_within(1.., 0);
|
||||
self.held_len -= 1;
|
||||
}
|
||||
let legato = self.held_len > 0;
|
||||
self.held[self.held_len] = n;
|
||||
self.held_len += 1;
|
||||
self.start(n as f32, legato && self.settings.glide > 0.);
|
||||
}
|
||||
Kind::NoteOff(n) if midi => {
|
||||
let len = self.held_len;
|
||||
let Some(i) = self.held[..len].iter().position(|h| *h == n) else {
|
||||
return;
|
||||
};
|
||||
let top = i + 1 == len;
|
||||
self.held.copy_within(i + 1..len, i);
|
||||
self.held_len -= 1;
|
||||
if self.held_len == 0 {
|
||||
self.stop();
|
||||
} else if top {
|
||||
let next = self.held[self.held_len - 1];
|
||||
self.start(next as f32, self.settings.glide > 0.);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Regular analysis; its window starts at the current output time.
|
||||
fn hop_analysis(&mut self) {
|
||||
if self.settings.source != Source::Audio || !self.sounding {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.estimate() else {
|
||||
return;
|
||||
};
|
||||
if self.pending {
|
||||
self.start(p, false);
|
||||
return;
|
||||
}
|
||||
if (p - self.tracked).abs() >= JUMP {
|
||||
match self.candidate {
|
||||
Some((c, count)) if (c - p).abs() < 0.5 => {
|
||||
if count + 1 >= 2 {
|
||||
self.start(p, self.settings.glide > 0.);
|
||||
} else {
|
||||
self.candidate = Some((p, count + 1));
|
||||
}
|
||||
}
|
||||
_ => self.candidate = Some((p, 1)),
|
||||
}
|
||||
} else {
|
||||
self.candidate = None;
|
||||
self.tracked += 0.3 * (p - self.tracked);
|
||||
if !self.settings.snap {
|
||||
self.voice.target = midi_to_hz(self.tracked + 12. * self.settings.octave as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn detect(&mut self, x: [f32; 2]) {
|
||||
let mono = if self.channels == 2 {
|
||||
0.5 * (x[0] + x[1])
|
||||
} else {
|
||||
x[0]
|
||||
};
|
||||
let d = self.detect[0].process(mono);
|
||||
let d = self.detect[1].process(d);
|
||||
if self.settings.source == Source::Audio {
|
||||
let a = d.abs();
|
||||
self.gate_env = if a > self.gate_env {
|
||||
a
|
||||
} else {
|
||||
self.gate_env * self.gate_release
|
||||
};
|
||||
let level = gain_to_db(self.gate_env);
|
||||
if level > self.settings.gate {
|
||||
self.gate_hold = (self.rate * GATE_HOLD_MS * 0.001) as usize;
|
||||
if !self.gate_open {
|
||||
self.gate_open = true;
|
||||
self.push(Kind::Onset);
|
||||
}
|
||||
} else if self.gate_open && level < self.settings.gate - GATE_HYSTERESIS_DB {
|
||||
if self.gate_hold == 0 {
|
||||
self.gate_open = false;
|
||||
self.push(Kind::Release);
|
||||
} else {
|
||||
self.gate_hold -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.decimate_count += 1;
|
||||
if self.decimate_count >= self.decimate {
|
||||
self.decimate_count = 0;
|
||||
self.ring[self.ring_at] = d;
|
||||
self.ring_at = (self.ring_at + 1) % self.ring.len();
|
||||
self.hop_count += 1;
|
||||
if self.hop_count >= self.hop {
|
||||
self.hop_count = 0;
|
||||
self.hop_analysis();
|
||||
}
|
||||
}
|
||||
self.tracker.note_off(note);
|
||||
}
|
||||
#[inline]
|
||||
fn shape(&self, x: f32) -> f32 {
|
||||
@@ -476,7 +246,7 @@ impl Engine {
|
||||
fn oscillator(&mut self) -> f32 {
|
||||
let attack = 1. / (self.rate * ATTACK_MS * 0.001);
|
||||
let release = 1. / (self.rate * RELEASE_MS * 0.001);
|
||||
let v = &mut self.voice;
|
||||
let v = &mut self.synth.voice;
|
||||
v.amp = if v.releasing {
|
||||
(v.amp - release).max(0.)
|
||||
} else {
|
||||
@@ -488,31 +258,28 @@ impl Engine {
|
||||
out += (TAU * v.phase).sin() * v.amp;
|
||||
v.phase = (v.phase + v.hz / self.rate).fract();
|
||||
}
|
||||
if self.old_gain > 0. {
|
||||
let o = &mut self.old;
|
||||
out += (TAU * o.phase).sin() * o.amp * self.old_gain;
|
||||
let s = &mut self.synth;
|
||||
if s.old_gain > 0. {
|
||||
let o = &mut s.old;
|
||||
out += (TAU * o.phase).sin() * o.amp * s.old_gain;
|
||||
o.phase = (o.phase + o.hz / self.rate).fract();
|
||||
self.old_gain = (self.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
|
||||
s.old_gain = (s.old_gain - 1. / (self.rate * CROSSFADE_MS * 0.001)).max(0.);
|
||||
}
|
||||
self.shape(out)
|
||||
}
|
||||
pub fn process_sample(&mut self, x: [f32; 2], monitor: Monitor) -> [f32; 2] {
|
||||
self.detect(x);
|
||||
let mono = if self.channels == 2 {
|
||||
0.5 * (x[0] + x[1])
|
||||
} else {
|
||||
x[0]
|
||||
};
|
||||
let (synth, settings) = (&mut self.synth, &self.settings);
|
||||
self.tracker.process(mono, |e| synth.event(settings, e));
|
||||
let len = self.delay.len();
|
||||
self.delay[self.delay_at] = x;
|
||||
self.delay_at = (self.delay_at + 1) % len;
|
||||
// The slot after the newest write is the oldest: exactly `latency` samples ago.
|
||||
let y = self.delay[self.delay_at];
|
||||
while self.event_len > 0 {
|
||||
let e = self.events[self.event_head];
|
||||
if e.time + self.latency as u64 > self.clock {
|
||||
break;
|
||||
}
|
||||
self.event_head = (self.event_head + 1) % EVENTS;
|
||||
self.event_len -= 1;
|
||||
self.handle(e.kind);
|
||||
}
|
||||
self.clock += 1;
|
||||
let s = self.settings;
|
||||
let (l0, h0) = self.crossover[0].process(y[0]);
|
||||
let (l1, h1) = if self.channels == 2 {
|
||||
@@ -523,11 +290,11 @@ impl Engine {
|
||||
let low = 0.5 * (l0 + l1);
|
||||
self.low_env += (low * low - self.low_env) * self.env_coef;
|
||||
let env = self.low_env.sqrt();
|
||||
if !self.voice.releasing {
|
||||
self.note_peak = self.note_peak.max(env);
|
||||
if !self.synth.voice.releasing {
|
||||
self.synth.note_peak = self.synth.note_peak.max(env);
|
||||
}
|
||||
let follow = if self.note_peak > 1e-6 {
|
||||
(env / self.note_peak).min(1.)
|
||||
let follow = if self.synth.note_peak > 1e-6 {
|
||||
(env / self.synth.note_peak).min(1.)
|
||||
} else {
|
||||
1.
|
||||
};
|
||||
@@ -570,9 +337,10 @@ impl Engine {
|
||||
scope_ms: (self.scope_stride * SCOPE) as f32 / self.rate * 1000.,
|
||||
..AnalyzerFrame::default()
|
||||
};
|
||||
if self.voice.amp > 0. && !self.voice.releasing {
|
||||
f.hz = self.voice.hz;
|
||||
f.note = Some(hz_to_midi(self.voice.hz).round().clamp(0., 127.) as u8);
|
||||
let v = self.synth.voice;
|
||||
if v.amp > 0. && !v.releasing {
|
||||
f.hz = v.hz;
|
||||
f.note = Some(hz_to_midi(v.hz).round().clamp(0., 127.) as u8);
|
||||
}
|
||||
// Trigger on the latest rising zero crossing that still leaves a full window behind it.
|
||||
let n = 2 * SCOPE;
|
||||
|
||||
Reference in new issue
Block a user