Improve phase trail and correlation controls
This commit is contained in:
@@ -143,8 +143,8 @@ Diese vorhandenen Funktionen sind nicht automatisch messtechnisch korrekt. Die f
|
||||
- **Abnahme:** Geringe Reaktionslatenz ohne unnötige Allokationen, bei vergleichbarem visuellen Nachleuchten.
|
||||
|
||||
- [ ] **15. Korrelation vollständig am RTW-Verhalten prüfen**
|
||||
- **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 1,0 s und 2,5 s sowie Negative-Peak-Memory.
|
||||
- **Ist:** Die Korrelation wird samplekontinuierlich im Audiokern aus gleich integrierten L²-, R²- und L·R-Leistungen berechnet. 1,0 s und 2,5 s sind wählbar; der Browser zeigt den fertigen Wert ohne zweite bildratenabhängige Glättung. Negative-Peak-Memory, Marker und manueller Reset sind implementiert.
|
||||
- **Soll:** Bereich -1 bis +1, passende Farben, wählbare Ansprechzeiten von 0,5 s, 1,0 s und 2,5 s sowie Negative-Peak-Memory.
|
||||
- **Ist:** Die Korrelation wird samplekontinuierlich im Audiokern aus gleich integrierten L²-, R²- und L·R-Leistungen berechnet. 0,5 s, 1,0 s und 2,5 s sind wählbar; ein konfigurierbarer Mono-RMS-Silence-Threshold setzt die Anzeige bei Grundrauschen auf Neutralstellung. Der Browser zeigt den fertigen Wert ohne zweite bildratenabhängige Glättung. Negative-Peak-Memory, Marker und manueller Reset sind implementiert.
|
||||
- **Geprüft:** Automatische Tests prüfen +1, 0 und -1, Stille, einseitiges Signal, beide Ansprechzeiten und den Memory-Reset.
|
||||
- **Noch offen:** Dynamische Phasenlagen und das exakte Zeit-/Memory-Verhalten müssen am PortaMonitor verglichen werden; deshalb bleibt der Gesamtpunkt offen.
|
||||
- **Abnahme:** Statische und dynamische Testsignale stimmen innerhalb der festgelegten Toleranz mit der Referenz überein.
|
||||
|
||||
@@ -16,4 +16,47 @@ assert.ok(Math.abs(twoFrames - context.smoothingAlpha(1 / 30, 0.2)) < 1e-12,
|
||||
assert.doesNotMatch(source, /auto\.gain \* PHASE_AGC_BASE_GAIN/,
|
||||
'AGC target gain must not be multiplied a second time');
|
||||
|
||||
const maxAngleStep = Number(
|
||||
source.match(/PHASE_TRAIL_MAX_ANGLE_STEP_RAD\s*=\s*\(([\d.]+)\s*\*\s*Math\.PI\)\s*\/\s*180/)?.[1],
|
||||
) * Math.PI / 180;
|
||||
assert.ok(Number.isFinite(maxAngleStep) && maxAngleStep <= (2 * Math.PI) / 180,
|
||||
'phase trail interpolation must limit angular steps to at most two degrees');
|
||||
|
||||
const interpolationSource = source.match(/function appendPhaseTrailSegment[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(interpolationSource, 'phase trail must interpolate polar segments');
|
||||
|
||||
const trailContext = vm.createContext({ Number, Math });
|
||||
const trailHelpers = [
|
||||
source.match(/const PHASE_TRAIL_MAX_ANGLE_STEP_RAD\s*=.*?;/)?.[0],
|
||||
source.match(/function radToDeg[\s\S]*?\n\}/)?.[0],
|
||||
source.match(/function trailColorForAngle[\s\S]*?\n\}/)?.[0],
|
||||
source.match(/function wrapAngle[\s\S]*?\n\}/)?.[0],
|
||||
source.match(/function lerp[\s\S]*?\n\}/)?.[0],
|
||||
source.match(/function createPhaseTrailPoint[\s\S]*?\n\}/)?.[0],
|
||||
interpolationSource,
|
||||
];
|
||||
assert.ok(trailHelpers.every(Boolean), 'phase trail interpolation helpers must remain testable');
|
||||
vm.runInContext(trailHelpers.join('\n'), trailContext);
|
||||
|
||||
const wheel = { cx: 0, cy: 0, radius: 100 };
|
||||
const trail = [trailContext.createPhaseTrailPoint(wheel, -Math.PI / 2, 1, 0)];
|
||||
trailContext.appendPhaseTrailSegment(trail, wheel, trail[0], Math.PI / 2, 1, 100);
|
||||
assert.equal(trail.length, 91,
|
||||
'a 180-degree phase change must be split into 90 two-degree segments');
|
||||
for (let index = 1; index < trail.length; index++) {
|
||||
const previousAngle = Math.atan2(trail[index - 1].y, trail[index - 1].x);
|
||||
const currentAngle = Math.atan2(trail[index].y, trail[index].x);
|
||||
const angularStep = Math.abs(Math.atan2(
|
||||
Math.sin(currentAngle - previousAngle),
|
||||
Math.cos(currentAngle - previousAngle),
|
||||
));
|
||||
assert.ok(angularStep <= maxAngleStep + 1e-12,
|
||||
'interpolated trail segments must respect the maximum angular step');
|
||||
assert.ok(Math.abs(Math.hypot(trail[index].x, trail[index].y) - wheel.radius) < 1e-9,
|
||||
'constant-radius phase movement must remain on a circular arc');
|
||||
}
|
||||
|
||||
assert.match(source, /PHASE_TRAIL_MAX_POINTS/,
|
||||
'interpolated phase trail history must remain bounded');
|
||||
|
||||
console.log('phase wheel regression tests passed');
|
||||
|
||||
@@ -5,10 +5,26 @@ import { getRtwCenters } from '../www/core/rtw_centers.js';
|
||||
import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
|
||||
const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8');
|
||||
const correlationNormalizerSource = configSource.match(
|
||||
/export function normalizeCorrelationResponseSeconds[\s\S]*?\n\}/,
|
||||
)?.[0];
|
||||
assert.ok(correlationNormalizerSource, 'correlation response normalization must remain testable');
|
||||
const correlationContext = vm.createContext({ Number });
|
||||
vm.runInContext(
|
||||
correlationNormalizerSource.replace('export function', 'function'),
|
||||
correlationContext,
|
||||
);
|
||||
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(0.5), 0.5);
|
||||
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(1.0), 1.0);
|
||||
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(2.5), 2.5);
|
||||
assert.equal(correlationContext.normalizeCorrelationResponseSeconds(Number.NaN), 1.0);
|
||||
|
||||
const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
|
||||
const context = vm.createContext({
|
||||
Number,
|
||||
normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds,
|
||||
getRtwCenters(mode) {
|
||||
if (mode === '1_12') return new Array(121).fill(0);
|
||||
if (mode === '1_6') return new Array(61).fill(0);
|
||||
@@ -29,6 +45,7 @@ const forced = context.buildRtaRuntimeConfig({
|
||||
RTA_PEAK_DECAY_DB_PER_S: 20,
|
||||
RTA_PEAK_RESET_TOKEN: 9,
|
||||
CORR_RESPONSE_S: 2.5,
|
||||
CORR_SILENCE_THRESHOLD_RMS_DBFS: -50,
|
||||
CORR_RESET_TOKEN: 7,
|
||||
XY_POINTS: 256,
|
||||
});
|
||||
@@ -44,9 +61,13 @@ assert.equal(forced.rtaPeakResetToken, 9);
|
||||
assert.equal(forced.tauFast, 0.125);
|
||||
assert.equal(forced.rtwCenters.length, 121);
|
||||
assert.equal(forced.correlationResponseS, 2.5);
|
||||
assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50);
|
||||
assert.equal(forced.correlationResetToken, 7);
|
||||
assert.equal(forced.xyPoints, 256);
|
||||
|
||||
const veryFastCorrelation = context.buildRtaRuntimeConfig({ CORR_RESPONSE_S: 0.5 });
|
||||
assert.equal(veryFastCorrelation.correlationResponseS, 0.5);
|
||||
|
||||
const extension = context.buildRtaRuntimeConfig({
|
||||
RTA_BAR_LAYOUT: 'iec',
|
||||
RTA_ENGINE: 'fft',
|
||||
@@ -59,7 +80,6 @@ assert.equal(extension.bpo, '1_12');
|
||||
assert.equal(extension.freqRange, 'lf');
|
||||
assert.equal(extension.order, 8);
|
||||
|
||||
const configSource = fs.readFileSync(new URL('../www/core/config.js', import.meta.url), 'utf8');
|
||||
const selectionSource = configSource.match(/function applyRtaBpoSelection[\s\S]*?\n\}/)?.[0];
|
||||
assert.ok(selectionSource, 'RTA BPO selection policy must remain testable');
|
||||
const selectionContext = vm.createContext({
|
||||
|
||||
+2
-1
@@ -628,7 +628,7 @@ impl Default for PpmState {
|
||||
true_peak_r: TruePeakDetector::default(),
|
||||
transport_clock: GoniometerClock::default(),
|
||||
transport_peaks: TransportPeaks::default(),
|
||||
correlation: CorrelationMeter::new(48_000, 1.0, 0),
|
||||
correlation: CorrelationMeter::new(48_000, 1.0, -75.0, 0),
|
||||
phase_wheel: PhaseWheelAnalyzer::new(48_000),
|
||||
xy_pending_l: Vec::with_capacity(1024),
|
||||
xy_pending_r: Vec::with_capacity(1024),
|
||||
@@ -1178,6 +1178,7 @@ fn process_audio_block(
|
||||
ppm_state.correlation.configure(
|
||||
sample_rate,
|
||||
rta_config.correlation_response_s,
|
||||
rta_config.correlation_silence_threshold_rms_dbfs,
|
||||
rta_config.correlation_reset_token,
|
||||
);
|
||||
|
||||
|
||||
+117
-16
@@ -9,39 +9,73 @@ pub struct CorrelationMeter {
|
||||
sample_rate: u32,
|
||||
response_seconds: f32,
|
||||
alpha: f64,
|
||||
gate_alpha: f64,
|
||||
silence_threshold_db: f32,
|
||||
silence_threshold_power: f64,
|
||||
power_l: f64,
|
||||
power_r: f64,
|
||||
cross_power: f64,
|
||||
gate_power: f64,
|
||||
value: f32,
|
||||
negative_peak: f32,
|
||||
reset_token: u64,
|
||||
}
|
||||
|
||||
impl CorrelationMeter {
|
||||
pub fn new(sample_rate: u32, response_seconds: f32, reset_token: u64) -> Self {
|
||||
pub fn new(
|
||||
sample_rate: u32,
|
||||
response_seconds: f32,
|
||||
silence_threshold_db: f32,
|
||||
reset_token: u64,
|
||||
) -> Self {
|
||||
let mut meter = Self {
|
||||
sample_rate: 0,
|
||||
response_seconds: 0.0,
|
||||
alpha: 1.0,
|
||||
gate_alpha: 1.0,
|
||||
silence_threshold_db: -75.0,
|
||||
silence_threshold_power: 10.0_f64.powf(-7.5),
|
||||
power_l: 0.0,
|
||||
power_r: 0.0,
|
||||
cross_power: 0.0,
|
||||
gate_power: 0.0,
|
||||
value: 0.0,
|
||||
negative_peak: 0.0,
|
||||
reset_token,
|
||||
};
|
||||
meter.configure(sample_rate, response_seconds, reset_token);
|
||||
meter.configure(
|
||||
sample_rate,
|
||||
response_seconds,
|
||||
silence_threshold_db,
|
||||
reset_token,
|
||||
);
|
||||
meter
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, sample_rate: u32, response_seconds: f32, reset_token: u64) {
|
||||
pub fn configure(
|
||||
&mut self,
|
||||
sample_rate: u32,
|
||||
response_seconds: f32,
|
||||
silence_threshold_db: f32,
|
||||
reset_token: u64,
|
||||
) {
|
||||
let sample_rate = sample_rate.max(8_000);
|
||||
let response_seconds = normalize_response_seconds(response_seconds);
|
||||
let silence_threshold_db = normalize_silence_threshold_db(silence_threshold_db);
|
||||
if self.sample_rate != sample_rate || self.response_seconds != response_seconds {
|
||||
self.sample_rate = sample_rate;
|
||||
self.response_seconds = response_seconds;
|
||||
self.alpha =
|
||||
1.0 - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp();
|
||||
// The silence decision must react independently of the selected
|
||||
// correlation response time. A 50 ms RMS envelope avoids both
|
||||
// sample-zero chatter and multi-second threshold lag.
|
||||
self.gate_alpha = 1.0 - (-1.0 / (f64::from(sample_rate) * 0.05)).exp();
|
||||
}
|
||||
if self.silence_threshold_db != silence_threshold_db {
|
||||
self.silence_threshold_db = silence_threshold_db;
|
||||
self.silence_threshold_power =
|
||||
10.0_f64.powf(f64::from(silence_threshold_db) / 10.0);
|
||||
}
|
||||
if self.reset_token != reset_token {
|
||||
self.reset_token = reset_token;
|
||||
@@ -55,12 +89,18 @@ impl CorrelationMeter {
|
||||
self.power_l += self.alpha * (l * l - self.power_l);
|
||||
self.power_r += self.alpha * (r * r - self.power_r);
|
||||
self.cross_power += self.alpha * (l * r - self.cross_power);
|
||||
self.gate_power += self.gate_alpha
|
||||
* (0.5 * (l * l + r * r) - self.gate_power);
|
||||
|
||||
// Below roughly -100 dBFS RMS per channel the quotient is no longer a
|
||||
// useful phase measurement and should settle at the neutral position.
|
||||
// Below the configured mono RMS threshold the correlation indication
|
||||
// settles at its neutral position.
|
||||
const MIN_POWER: f64 = 1.0e-10;
|
||||
let denominator = (self.power_l * self.power_r).sqrt();
|
||||
self.value = if self.power_l > MIN_POWER && self.power_r > MIN_POWER && denominator > 0.0 {
|
||||
self.value = if self.gate_power >= self.silence_threshold_power
|
||||
&& self.power_l > MIN_POWER
|
||||
&& self.power_r > MIN_POWER
|
||||
&& denominator > 0.0
|
||||
{
|
||||
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
@@ -80,10 +120,22 @@ impl CorrelationMeter {
|
||||
}
|
||||
|
||||
pub fn normalize_response_seconds(value: f32) -> f32 {
|
||||
if value.is_finite() && value >= 1.75 {
|
||||
2.5
|
||||
} else {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
1.0
|
||||
} else if value < 0.75 {
|
||||
0.5
|
||||
} else if value < 1.75 {
|
||||
1.0
|
||||
} else {
|
||||
2.5
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_silence_threshold_db(value: f32) -> f32 {
|
||||
if value.is_finite() {
|
||||
value.clamp(-90.0, -40.0)
|
||||
} else {
|
||||
-75.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +163,7 @@ mod tests {
|
||||
(std::f32::consts::PI, -1.0),
|
||||
(std::f32::consts::FRAC_PI_2, 0.0),
|
||||
] {
|
||||
let mut meter = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||
let mut meter = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
|
||||
run_signal(&mut meter, 5, |index| {
|
||||
let angle = phase_step * index as f32;
|
||||
(angle.sin(), (angle + phase).sin())
|
||||
@@ -127,8 +179,8 @@ mod tests {
|
||||
#[test]
|
||||
fn response_time_and_peak_reset_are_deterministic() {
|
||||
let sample_rate = 48_000;
|
||||
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0);
|
||||
let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
|
||||
let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0);
|
||||
for index in 0..sample_rate as usize {
|
||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||
fast.process(sample, sample);
|
||||
@@ -141,33 +193,82 @@ mod tests {
|
||||
(sample, -sample)
|
||||
});
|
||||
assert!(fast.negative_peak() < -0.7);
|
||||
fast.configure(sample_rate, 1.0, 1);
|
||||
fast.configure(sample_rate, 1.0, -75.0, 1);
|
||||
assert_eq!(fast.negative_peak(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_response_tracks_a_phase_reversal_before_slow_response() {
|
||||
let sample_rate = 48_000;
|
||||
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0);
|
||||
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0);
|
||||
let mut very_fast = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0);
|
||||
let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
|
||||
let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0);
|
||||
for index in 0..sample_rate as usize * 5 {
|
||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||
very_fast.process(sample, sample);
|
||||
fast.process(sample, sample);
|
||||
slow.process(sample, sample);
|
||||
}
|
||||
for index in 0..sample_rate as usize {
|
||||
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
|
||||
very_fast.process(sample, -sample);
|
||||
fast.process(sample, -sample);
|
||||
slow.process(sample, -sample);
|
||||
}
|
||||
assert!(very_fast.value() < fast.value() - 0.35);
|
||||
assert!(fast.value() < slow.value() - 0.35);
|
||||
assert!(very_fast.negative_peak() < fast.negative_peak());
|
||||
assert!(fast.negative_peak() < 0.0);
|
||||
assert_eq!(slow.negative_peak(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_time_normalization_accepts_all_three_profiles() {
|
||||
assert_eq!(normalize_response_seconds(0.5), 0.5);
|
||||
assert_eq!(normalize_response_seconds(1.0), 1.0);
|
||||
assert_eq!(normalize_response_seconds(2.5), 2.5);
|
||||
assert_eq!(normalize_response_seconds(f32::NAN), 1.0);
|
||||
assert_eq!(normalize_response_seconds(0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurable_silence_threshold_neutralizes_the_meter() {
|
||||
let sample_rate = 48_000;
|
||||
let amplitude = 10.0_f32.powf(-64.0 / 20.0);
|
||||
let mut meter = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0);
|
||||
run_signal(&mut meter, 1, |index| {
|
||||
let sample = if index & 1 == 0 {
|
||||
amplitude
|
||||
} else {
|
||||
-amplitude
|
||||
};
|
||||
(sample, sample)
|
||||
});
|
||||
assert!(meter.value() > 0.99);
|
||||
|
||||
meter.configure(sample_rate, 0.5, -50.0, 0);
|
||||
run_signal(&mut meter, 1, |index| {
|
||||
let sample = if index & 1 == 0 {
|
||||
amplitude
|
||||
} else {
|
||||
-amplitude
|
||||
};
|
||||
(sample, sample)
|
||||
});
|
||||
assert_eq!(meter.value(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_threshold_is_normalized_to_the_ui_range() {
|
||||
assert_eq!(normalize_silence_threshold_db(-50.0), -50.0);
|
||||
assert_eq!(normalize_silence_threshold_db(-100.0), -90.0);
|
||||
assert_eq!(normalize_silence_threshold_db(-20.0), -40.0);
|
||||
assert_eq!(normalize_silence_threshold_db(f32::NAN), -75.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_and_single_channel_are_neutral() {
|
||||
let mut meter = CorrelationMeter::new(48_000, 1.0, 0);
|
||||
let mut meter = CorrelationMeter::new(48_000, 1.0, -75.0, 0);
|
||||
run_signal(&mut meter, 2, |_| (0.0, 0.0));
|
||||
assert_eq!(meter.value(), 0.0);
|
||||
assert_eq!(meter.negative_peak(), 0.0);
|
||||
|
||||
@@ -82,6 +82,7 @@ pub struct PhoenixRtaConfig {
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
pub correlation_response_s: f32,
|
||||
pub correlation_silence_threshold_rms_dbfs: f32,
|
||||
pub correlation_reset_token: u64,
|
||||
pub xy_points: u32,
|
||||
}
|
||||
@@ -118,6 +119,7 @@ impl Default for PhoenixRtaConfig {
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
correlation_response_s: 1.0,
|
||||
correlation_silence_threshold_rms_dbfs: -75.0,
|
||||
correlation_reset_token: 0,
|
||||
xy_points: 1024,
|
||||
}
|
||||
|
||||
@@ -487,6 +487,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
||||
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
||||
config.correlation_response_s =
|
||||
crate::correlation::normalize_response_seconds(config.correlation_response_s);
|
||||
config.correlation_silence_threshold_rms_dbfs =
|
||||
crate::correlation::normalize_silence_threshold_db(
|
||||
config.correlation_silence_threshold_rms_dbfs,
|
||||
);
|
||||
config.xy_points = match config.xy_points {
|
||||
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
||||
n if n < 192 => 128,
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<li><strong>Hardwareparameter:</strong> Tatsächlich von ALSA ausgehandelte Samplerate, Perioden- und Puffergröße werden nun durch DSP, Aufnahme, Status und Browser verwendet; die feste 48-kHz-Annahme wurde entfernt.</li>
|
||||
<li><strong>Goniometer:</strong> Nur neue XY-Samplepaare werden mit einer periodengrößenunabhängigen mittleren Rate von 60 Hz übertragen. Punktbegrenzung wirkt bereits auf den Transport; begrenzte und wiederverwendbare Spurpuffer reduzieren Speicher- und Zeichenlast.</li>
|
||||
<li><strong>Goniometer-Persistenz:</strong> Reproduzierbare Fast-, Medium- und Slow-Profile sowie ein freies Phoenix-Fade ergänzt. Künstliche Bézier-Verformung der Messspur entfernt; AGC und Silence-Gate arbeiten zeit- beziehungsweise fensterbasiert.</li>
|
||||
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 1,0/2,5 Sekunden ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.</li>
|
||||
<li><strong>Korrelation:</strong> Kontinuierliche DSP-Messung aus L², R² und L·R mit wählbaren 0,5/1,0/2,5 Sekunden und konfigurierbarem Mono-RMS-Silence-Threshold ergänzt. Bildratenabhängige Doppelglättung entfernt; Negative-Peak-Memory, Marker und manueller Reset hinzugefügt.</li>
|
||||
<li><strong>Phasenrad:</strong> Bandpass, Hilbert-Transformation und energiegewichtete L/R-Phasenmittelung laufen nun samplekontinuierlich im Audiokern statt auf ausgedünnten Browser-XY-Punkten. Paketgrenzen beeinflussen den Winkel nicht mehr; Glättung und AGC arbeiten zeitbasiert, und die doppelte AGC-Verstärkung wurde entfernt.</li>
|
||||
<li><strong>True Peak, RMS und VU:</strong> True Peak verwendet den vierphasigen Referenz-FIR aus ITU-R BS.1770, besteht die EBU-Testfälle 15 bis 19 und zeigt Übersteuerungen in allen Ansichten bis +6 dBTP an. True RMS integriert samplekontinuierlich im Leistungsbereich mit Fast (125 ms), Slow (1 s) oder einem gleitenden 300-ms-Fenster; Browser-Doppelglättung und der RMS-fremde Impulse-Modus wurden entfernt. VU verwendet ein Moving-Coil-Modell mit 300-ms-Sprungantwort und 1 bis 1,5 % Überschwingen; der fälschliche Standardoffset von +1,92 dB wurde auf 0 dB korrigiert.</li>
|
||||
<li><strong>Qualitätssicherung:</strong> Automatische Regressionstests für PPM, RTA, A/C/Z, mehrere Sampleraten, Korrelationssignale, Goniometertaktung, Binärprotokolle sowie Spektrogramm-Zeitbasis und Langlauf ergänzt.</li>
|
||||
|
||||
+10
-2
@@ -1,7 +1,12 @@
|
||||
// core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers
|
||||
// Wird von main.js mit `initAudio(env)` gestartet. Nutzt env.config (CONFIG) und env.meters.
|
||||
|
||||
import { applyPhoenixGlobalConfig, buildPhoenixGlobalConfigPayload, saveConfig } from './config.js';
|
||||
import {
|
||||
applyPhoenixGlobalConfig,
|
||||
buildPhoenixGlobalConfigPayload,
|
||||
normalizeCorrelationResponseSeconds,
|
||||
saveConfig,
|
||||
} from './config.js';
|
||||
import { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js';
|
||||
import { getRtwCenters } from './rtw_centers.js';
|
||||
|
||||
@@ -943,7 +948,10 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
|
||||
ppmEbuDecayDbPerS: 24 / 2.8,
|
||||
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
|
||||
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED,
|
||||
correlationResponseS: Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0,
|
||||
correlationResponseS: normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S),
|
||||
correlationSilenceThresholdRmsDbfs: Number.isFinite(CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS)
|
||||
? Math.max(-90, Math.min(-40, CONFIG.CORR_SILENCE_THRESHOLD_RMS_DBFS))
|
||||
: -75,
|
||||
correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)),
|
||||
xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS))
|
||||
? Number(CONFIG.XY_POINTS)
|
||||
|
||||
+9
-1
@@ -1,5 +1,13 @@
|
||||
// core/config.js — zentrale CONFIG + Presets + Persistenz
|
||||
|
||||
export function normalizeCorrelationResponseSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return 1.0;
|
||||
if (seconds < 0.75) return 0.5;
|
||||
if (seconds < 1.75) return 1.0;
|
||||
return 2.5;
|
||||
}
|
||||
|
||||
function isLoopbackHost(host) {
|
||||
const h = String(host || '').trim().toLowerCase();
|
||||
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
|
||||
@@ -971,7 +979,7 @@ function loadConfig(opts = {}) {
|
||||
const fallback = Number.isFinite(CONFIG.GONIO_DISPLAY_GAIN_DB) ? CONFIG.GONIO_DISPLAY_GAIN_DB : 0;
|
||||
CONFIG.GONIO_MANUAL_GAIN_DB = Math.max(-35, Math.min(35, Math.round(fallback / 5) * 5));
|
||||
}
|
||||
CONFIG.CORR_RESPONSE_S = Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1.0;
|
||||
CONFIG.CORR_RESPONSE_S = normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S);
|
||||
const corrNegativeMarkerMode = String(CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory').toLowerCase();
|
||||
CONFIG.CORR_NEGATIVE_MARKER_MODE = ['memory', 'hold', 'off'].includes(corrNegativeMarkerMode)
|
||||
? corrNegativeMarkerMode
|
||||
|
||||
+2
-1
@@ -521,7 +521,7 @@
|
||||
<input id="opt_xySilenceGate" type="checkbox" checked>
|
||||
Silence-Gate aktivieren (RMS-basiert)
|
||||
</label>
|
||||
<small>Blendet nur die Goniometer-Spur bei Stille aus; die Korrelation wird unabhängig kontinuierlich gemessen.</small>
|
||||
<small>Blendet die Goniometer-Spur bei Stille aus; der Threshold setzt außerdem die Korrelation auf Neutralstellung.</small>
|
||||
</div>
|
||||
<div class="opt">
|
||||
<label>Silence-Threshold [dBFS RMS]</label>
|
||||
@@ -530,6 +530,7 @@
|
||||
</div>
|
||||
<div class="opt"><label>Korrelation Ansprechzeit</label>
|
||||
<select id="opt_corrResponse">
|
||||
<option value="0.5">0,5 s</option>
|
||||
<option value="1">1,0 s</option>
|
||||
<option value="2.5">2,5 s</option>
|
||||
</select>
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
// ui/options.js — bindet das Options-Panel an CONFIG (lesen/schreiben), Presets, Export/Import
|
||||
// Erwartet vorhandenes DOM aus index.html. Nutzt localStorage-Key 'analyzer_config'.
|
||||
|
||||
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain } from '../core/config.js';
|
||||
import { CONFIG, saveConfig, loadConfig, applyAlignmentProfile, applyRtaBpoSelection, applySoftwarePreset, loadSoftwarePreset, saveSoftwarePreset, loadLayoutPreset, saveLayoutPreset, exportSoftwarePreset, importSoftwarePreset, updateVuReference, resetConfigToDefaults, updateInputOffsetGain, normalizeCorrelationResponseSeconds } from '../core/config.js';
|
||||
import { clamp, clampPow2 } from '../core/utils.js';
|
||||
import { bindOptionsViewportAssist } from './options_viewport.js';
|
||||
import { setupLazyChangelog } from './changelog.js';
|
||||
@@ -184,7 +184,7 @@ function syncUI() {
|
||||
['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL],
|
||||
['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN],
|
||||
|
||||
['opt_corrResponse', String(Number(CONFIG.CORR_RESPONSE_S) >= 1.75 ? 2.5 : 1)],
|
||||
['opt_corrResponse', String(normalizeCorrelationResponseSeconds(CONFIG.CORR_RESPONSE_S))],
|
||||
['opt_corrNegativeMarkerMode', CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory'],
|
||||
['opt_xyPoints', String(CONFIG.XY_POINTS)],
|
||||
['opt_xyStyle', CONFIG.XY_STYLE],
|
||||
@@ -457,7 +457,8 @@ function wireHandlers(env) {
|
||||
if (!el) return;
|
||||
const notifyRta = /^opt_rta/i.test(id)
|
||||
|| id === 'opt_lufsIWindowMin'
|
||||
|| id === 'opt_lufsINorm';
|
||||
|| id === 'opt_lufsINorm'
|
||||
|| id === 'opt_xySilenceThr';
|
||||
const inputType = String(el.type || '').toLowerCase();
|
||||
const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color';
|
||||
|
||||
@@ -813,7 +814,7 @@ function wireHandlers(env) {
|
||||
});
|
||||
|
||||
h('opt_corrResponse', v => {
|
||||
CONFIG.CORR_RESPONSE_S = Number(v) >= 1.75 ? 2.5 : 1.0;
|
||||
CONFIG.CORR_RESPONSE_S = normalizeCorrelationResponseSeconds(v);
|
||||
try { env?.notifyRtaConfig?.(); } catch (_) {}
|
||||
return String(CONFIG.CORR_RESPONSE_S);
|
||||
});
|
||||
|
||||
+50
-12
@@ -29,6 +29,8 @@ const PHASE_RADIUS_TAU_S = 0.085;
|
||||
const PHASE_TRAIL_FADE_MS = 2000;
|
||||
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
|
||||
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
|
||||
const PHASE_TRAIL_MAX_ANGLE_STEP_RAD = (2 * Math.PI) / 180;
|
||||
const PHASE_TRAIL_MAX_POINTS = 1024;
|
||||
const PHASE_SECTORS = [
|
||||
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
|
||||
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
|
||||
@@ -517,31 +519,64 @@ function updatePhaseTrail(state, wheel, CONFIG, now) {
|
||||
}
|
||||
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
|
||||
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
|
||||
const length = radiusNorm * wheel.radius;
|
||||
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
|
||||
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
|
||||
const color = trailColorForAngle(state.smoothPhase);
|
||||
const phase = wrapAngle(state.smoothPhase);
|
||||
const point = createPhaseTrailPoint(wheel, phase, radiusNorm, now);
|
||||
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
|
||||
if (!last) {
|
||||
state.phaseTrail.push({ x, y, t: now, color });
|
||||
state.phaseTrail.push(point);
|
||||
} else {
|
||||
const dt = now - last.t;
|
||||
const dx = x - last.x;
|
||||
const dy = y - last.y;
|
||||
const dx = point.x - last.x;
|
||||
const dy = point.y - last.y;
|
||||
const dist2 = dx * dx + dy * dy;
|
||||
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== color) {
|
||||
state.phaseTrail.push({ x, y, t: now, color });
|
||||
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== point.color) {
|
||||
appendPhaseTrailSegment(state.phaseTrail, wheel, last, phase, radiusNorm, now);
|
||||
} else {
|
||||
last.x = x;
|
||||
last.y = y;
|
||||
last.x = point.x;
|
||||
last.y = point.y;
|
||||
last.t = now;
|
||||
last.color = color;
|
||||
last.color = point.color;
|
||||
last.phase = phase;
|
||||
last.radiusNorm = radiusNorm;
|
||||
}
|
||||
}
|
||||
}
|
||||
trimPhaseTrail(state, CONFIG, now);
|
||||
}
|
||||
|
||||
function createPhaseTrailPoint(wheel, phase, radiusNorm, timestamp) {
|
||||
const length = radiusNorm * wheel.radius;
|
||||
return {
|
||||
x: wheel.cx + Math.cos(phase) * length,
|
||||
y: wheel.cy + Math.sin(phase) * length,
|
||||
t: timestamp,
|
||||
color: trailColorForAngle(phase),
|
||||
phase,
|
||||
radiusNorm,
|
||||
};
|
||||
}
|
||||
|
||||
function appendPhaseTrailSegment(trail, wheel, last, phase, radiusNorm, timestamp) {
|
||||
const startPhase = Number.isFinite(last?.phase) ? last.phase : phase;
|
||||
const startRadius = Number.isFinite(last?.radiusNorm) ? last.radiusNorm : radiusNorm;
|
||||
const phaseDelta = wrapAngle(phase - startPhase);
|
||||
const steps = Math.max(1, Math.ceil(Math.abs(phaseDelta) / PHASE_TRAIL_MAX_ANGLE_STEP_RAD));
|
||||
const startTime = Number.isFinite(last?.t) ? last.t : timestamp;
|
||||
|
||||
for (let step = 1; step <= steps; step++) {
|
||||
const fraction = step / steps;
|
||||
const interpolatedPhase = wrapAngle(startPhase + phaseDelta * fraction);
|
||||
const interpolatedRadius = lerp(startRadius, radiusNorm, fraction);
|
||||
const interpolatedTime = lerp(startTime, timestamp, fraction);
|
||||
trail.push(createPhaseTrailPoint(
|
||||
wheel,
|
||||
interpolatedPhase,
|
||||
interpolatedRadius,
|
||||
interpolatedTime,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function trimPhaseTrail(state, CONFIG, now) {
|
||||
if (!state.phaseTrail) state.phaseTrail = [];
|
||||
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
||||
@@ -557,6 +592,9 @@ function trimPhaseTrail(state, CONFIG, now) {
|
||||
}
|
||||
}
|
||||
state.phaseTrail.length = write;
|
||||
if (state.phaseTrail.length > PHASE_TRAIL_MAX_POINTS) {
|
||||
state.phaseTrail.splice(0, state.phaseTrail.length - PHASE_TRAIL_MAX_POINTS);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
|
||||
|
||||
Reference in New Issue
Block a user