Cut core/instrument/engine comment bloat ~33% (comments only, zero code change)
This commit is contained in:
@@ -1,24 +1,15 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
|
||||
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
|
||||
// the naive dual-tap OLA with correlation-aligned splices.
|
||||
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
|
||||
//
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
||||
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
|
||||
// sample (source rate, duration preserved). One active read tap advances by the shift
|
||||
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
|
||||
// one window (+window toward older content for up-shifts, -window toward the writer for
|
||||
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
|
||||
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
|
||||
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
|
||||
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
|
||||
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
|
||||
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
|
||||
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
|
||||
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
|
||||
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
|
||||
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
|
||||
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
|
||||
// one window — clamped to the filled span so it never lands in unwritten silence — refined
|
||||
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
|
||||
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
|
||||
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
|
||||
// with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
|
||||
// At unity ratio the delay is frozen mid-band and no splice ever fires.
|
||||
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
|
||||
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
ringLen_ = 2 * window_;
|
||||
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
||||
// Geometry (all quarters of the window):
|
||||
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
|
||||
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
|
||||
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
|
||||
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
|
||||
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
|
||||
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
|
||||
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
|
||||
// between the tap and the writer — the cap expresses that safety rather than leaving
|
||||
// it coincidental. 512 bounds the splice burst.
|
||||
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
|
||||
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
|
||||
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
|
||||
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
|
||||
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
|
||||
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
|
||||
// the splice burst.
|
||||
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
||||
maxLag_ = window_ / 4;
|
||||
dLow_ = window_ / 4;
|
||||
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
|
||||
void PitchShifter::reset() {
|
||||
if (window_ > 1) {
|
||||
// Zero the ring and seed the active tap one window behind the writer — the exact
|
||||
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
|
||||
// forever and either shift direction has maximal drift room. No history is declared
|
||||
// (filled_ = 0): follow with prime() or warm() before streaming.
|
||||
// Seed the active tap one window behind the writer — the exact middle of the safe
|
||||
// band [dLow, dHigh], so unity holds it there forever with maximal drift room either
|
||||
// direction. No history declared (filled_ = 0): follow with prime() or warm().
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
writePos_ = 0;
|
||||
posA_ = static_cast<double>(ringLen_ - window_);
|
||||
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
|
||||
void PitchShifter::freezeTail() {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
tailFrozen_ = true;
|
||||
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
|
||||
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
|
||||
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
|
||||
// continuous at the freeze frame (no gain step); see the re-anchor block below.
|
||||
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
|
||||
if (fading_) {
|
||||
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
||||
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
|
||||
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
|
||||
// freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
|
||||
const double tOld =
|
||||
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||
double dB = static_cast<double>(writePos_) - posB_;
|
||||
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
|
||||
|
||||
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
|
||||
if (window_ <= 1) return; // pass-through needs no priming
|
||||
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
|
||||
// count must stay inside the safe band so the seed does not itself trigger a splice.
|
||||
// Clamp to one window: delay == count must stay inside the safe band so the seed itself
|
||||
// never triggers a splice.
|
||||
if (count < 0) count = 0;
|
||||
if (count > window_) count = window_;
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
@@ -189,24 +174,21 @@ double PitchShifter::readTap(double pos) const {
|
||||
}
|
||||
|
||||
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
|
||||
// older content, -window_ = jump toward the writer), refined by a correlation search so
|
||||
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
|
||||
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
|
||||
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
|
||||
// multiply-adds, once per splice.
|
||||
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
|
||||
// content, -window_ = toward the writer), refined by a correlation search so the relocated
|
||||
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
|
||||
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
|
||||
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
|
||||
// per splice.
|
||||
const std::int64_t d = static_cast<std::int64_t>(delay);
|
||||
|
||||
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
|
||||
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
|
||||
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
|
||||
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
|
||||
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
|
||||
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
|
||||
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
|
||||
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
|
||||
// what real history exists (still many source periods with a full-window prime). The floor of
|
||||
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
|
||||
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
|
||||
// parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
|
||||
// so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
|
||||
// extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
|
||||
// (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
|
||||
// onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
|
||||
// degenerate reset-without-prime path.
|
||||
std::int64_t jump = nominalJump;
|
||||
if (jump > 0) {
|
||||
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
|
||||
@@ -233,12 +215,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
if (++ia >= ringLen_) ia = 0;
|
||||
if (++ic >= ringLen_) ic = 0;
|
||||
}
|
||||
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
|
||||
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
|
||||
// loudest candidate over the best-ALIGNED one — a small level step per splice that
|
||||
// the amplitude-complementary fade cannot hide. The reference segment's energy is
|
||||
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
|
||||
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
|
||||
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
|
||||
// so on a decaying tail every up-splice would prefer the loudest candidate over the
|
||||
// best-aligned one. The reference segment's energy is constant across lags, so dividing
|
||||
// by sqrt(Ec) alone ranks identically to the full normalized form.
|
||||
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
|
||||
};
|
||||
|
||||
@@ -261,13 +241,11 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
}
|
||||
}
|
||||
|
||||
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
|
||||
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
|
||||
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
|
||||
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
|
||||
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
|
||||
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
|
||||
// silence) keeps the integer lag, which is already benign there.
|
||||
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
|
||||
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
|
||||
// comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
|
||||
// fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
|
||||
// negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
|
||||
double frac = 0.0;
|
||||
{
|
||||
const double sM = scoreAt(bestLag - 1);
|
||||
@@ -287,22 +265,17 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
|
||||
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
|
||||
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
|
||||
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
|
||||
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
|
||||
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
|
||||
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
|
||||
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
|
||||
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
|
||||
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
|
||||
// mid-fade is covered by the same margin for any realistic per-frame bias.
|
||||
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
|
||||
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
|
||||
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
|
||||
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
|
||||
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
|
||||
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
|
||||
// so they always keep the full fade.
|
||||
//
|
||||
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
|
||||
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
|
||||
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
|
||||
// included: splices fire in the frozen tail because the delay now drains at unity too).
|
||||
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
|
||||
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
|
||||
// applies at every ratio (including unity, since delay now drains at unity too).
|
||||
fadeLen_ = fadeFrames_;
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
||||
if (drainRate > 0.0) {
|
||||
@@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
}
|
||||
fading_ = true;
|
||||
fadePos_ = 0;
|
||||
// Record the decision for a linked follower channel (T1-01): the follower applies this
|
||||
// verbatim so both channels share one lag and one splice schedule.
|
||||
// Record the decision for a linked follower channel — applied verbatim there so both
|
||||
// channels share one lag and one splice schedule.
|
||||
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
|
||||
}
|
||||
|
||||
void PitchShifter::applySplice(const SpliceEvent& ev) {
|
||||
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
|
||||
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
|
||||
// which match ours by the lockstep contract (identical configure/prime/ratio history);
|
||||
// the fade length likewise derives only from shared geometry + ratio.
|
||||
// Follower half of the linked lag: relocate + fade with the master's decision, no
|
||||
// correlation search of our own — the master's jump/fade derive from shared geometry +
|
||||
// ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
|
||||
posB_ = posA_;
|
||||
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
@@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||
|
||||
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
|
||||
// 5 plain fields, negligible on the RT path).
|
||||
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
|
||||
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
||||
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
|
||||
|
||||
// 1. Write the incoming sample at the write head (source rate). One more slot of the
|
||||
// ring now holds valid history (capped at the ring length once it has wrapped).
|
||||
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
|
||||
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
|
||||
// the read/splice/fade machinery below runs unchanged over the frozen content.
|
||||
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
|
||||
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
|
||||
// below run unchanged over the frozen content.
|
||||
if (!tailFrozen_) {
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
}
|
||||
|
||||
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
||||
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
|
||||
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||
// Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
||||
// Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
|
||||
// phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||
double out = readTap(posA_);
|
||||
if (fading_) {
|
||||
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||
@@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
||||
if (++fadePos_ >= fadeLen_) fading_ = false;
|
||||
} else if (linked != nullptr) {
|
||||
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
|
||||
// master channel did this frame. Lockstep state means our own trigger would have
|
||||
// fired on the same frame; applying the master's decision keeps the two rings
|
||||
// sample-aligned (one shared lag, one shared schedule).
|
||||
// Follower: no trigger test, no search — splice exactly when and how the master did
|
||||
// this frame (lockstep means our own trigger would have fired the same frame anyway).
|
||||
if (linkedEv.fired) {
|
||||
applySplice(linkedEv);
|
||||
} else {
|
||||
// Self-healing fallback (review rider): the master not firing normally means this
|
||||
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
|
||||
// renders a mono block mid-note, this follower channel is skipped for that block
|
||||
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
|
||||
// only the `if (linkedEv.fired)` path above, could never resync. So check this
|
||||
// follower's OWN tap distance against the safe band and splice via its own search
|
||||
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
|
||||
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
|
||||
// never triggers: the master's trigger fires first and this whole `if` is false.
|
||||
// Self-healing fallback: if the processor ever renders a mono block mid-note, this
|
||||
// follower is skipped for that block while the master keeps advancing, and could
|
||||
// never resync via the `linkedEv.fired` path alone. So also check this follower's
|
||||
// own tap distance against the safe band and splice via its own search when it has
|
||||
// left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
|
||||
// the master's trigger always fires first.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
|
||||
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
|
||||
// unity the delay is frozen at window/2 and neither trigger ever fires.
|
||||
// Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||
// Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
|
||||
// toward the ring length -> jump one window toward the writer. At unity the delay is
|
||||
// frozen at window/2 and neither trigger ever fires.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
@@ -414,8 +378,7 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
|
||||
// tap(s) by the shift ratio.
|
||||
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
|
||||
if (!tailFrozen_) {
|
||||
++writePos_;
|
||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||
|
||||
Reference in New Issue
Block a user