Improve phase trail and correlation controls

This commit is contained in:
Mikei386
2026-07-27 18:13:07 +02:00
parent 00fb59f643
commit a4fa0ceb47
13 changed files with 268 additions and 41 deletions
+2 -2
View File
@@ -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. - **Abnahme:** Geringe Reaktionslatenz ohne unnötige Allokationen, bei vergleichbarem visuellen Nachleuchten.
- [ ] **15. Korrelation vollständig am RTW-Verhalten prüfen** - [ ] **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. - **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. 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. - **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. - **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. - **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. - **Abnahme:** Statische und dynamische Testsignale stimmen innerhalb der festgelegten Toleranz mit der Referenz überein.
+43
View File
@@ -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/, assert.doesNotMatch(source, /auto\.gain \* PHASE_AGC_BASE_GAIN/,
'AGC target gain must not be multiplied a second time'); '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'); console.log('phase wheel regression tests passed');
+21 -1
View File
@@ -5,10 +5,26 @@ import { getRtwCenters } from '../www/core/rtw_centers.js';
import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js'; import { buildRtwTickPositions, selectLocalRtwPacket } from '../www/views/realtime.js';
const source = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8'); 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]; const functionSource = source.match(/export function buildRtaRuntimeConfig[\s\S]*?\n\}/)?.[0];
assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable'); assert.ok(functionSource, 'buildRtaRuntimeConfig must remain testable');
const context = vm.createContext({ const context = vm.createContext({
Number, Number,
normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds,
getRtwCenters(mode) { getRtwCenters(mode) {
if (mode === '1_12') return new Array(121).fill(0); if (mode === '1_12') return new Array(121).fill(0);
if (mode === '1_6') return new Array(61).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_DECAY_DB_PER_S: 20,
RTA_PEAK_RESET_TOKEN: 9, RTA_PEAK_RESET_TOKEN: 9,
CORR_RESPONSE_S: 2.5, CORR_RESPONSE_S: 2.5,
CORR_SILENCE_THRESHOLD_RMS_DBFS: -50,
CORR_RESET_TOKEN: 7, CORR_RESET_TOKEN: 7,
XY_POINTS: 256, XY_POINTS: 256,
}); });
@@ -44,9 +61,13 @@ assert.equal(forced.rtaPeakResetToken, 9);
assert.equal(forced.tauFast, 0.125); assert.equal(forced.tauFast, 0.125);
assert.equal(forced.rtwCenters.length, 121); assert.equal(forced.rtwCenters.length, 121);
assert.equal(forced.correlationResponseS, 2.5); assert.equal(forced.correlationResponseS, 2.5);
assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50);
assert.equal(forced.correlationResetToken, 7); assert.equal(forced.correlationResetToken, 7);
assert.equal(forced.xyPoints, 256); assert.equal(forced.xyPoints, 256);
const veryFastCorrelation = context.buildRtaRuntimeConfig({ CORR_RESPONSE_S: 0.5 });
assert.equal(veryFastCorrelation.correlationResponseS, 0.5);
const extension = context.buildRtaRuntimeConfig({ const extension = context.buildRtaRuntimeConfig({
RTA_BAR_LAYOUT: 'iec', RTA_BAR_LAYOUT: 'iec',
RTA_ENGINE: 'fft', RTA_ENGINE: 'fft',
@@ -59,7 +80,6 @@ assert.equal(extension.bpo, '1_12');
assert.equal(extension.freqRange, 'lf'); assert.equal(extension.freqRange, 'lf');
assert.equal(extension.order, 8); 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]; const selectionSource = configSource.match(/function applyRtaBpoSelection[\s\S]*?\n\}/)?.[0];
assert.ok(selectionSource, 'RTA BPO selection policy must remain testable'); assert.ok(selectionSource, 'RTA BPO selection policy must remain testable');
const selectionContext = vm.createContext({ const selectionContext = vm.createContext({
+2 -1
View File
@@ -628,7 +628,7 @@ impl Default for PpmState {
true_peak_r: TruePeakDetector::default(), true_peak_r: TruePeakDetector::default(),
transport_clock: GoniometerClock::default(), transport_clock: GoniometerClock::default(),
transport_peaks: TransportPeaks::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), phase_wheel: PhaseWheelAnalyzer::new(48_000),
xy_pending_l: Vec::with_capacity(1024), xy_pending_l: Vec::with_capacity(1024),
xy_pending_r: Vec::with_capacity(1024), xy_pending_r: Vec::with_capacity(1024),
@@ -1178,6 +1178,7 @@ fn process_audio_block(
ppm_state.correlation.configure( ppm_state.correlation.configure(
sample_rate, sample_rate,
rta_config.correlation_response_s, rta_config.correlation_response_s,
rta_config.correlation_silence_threshold_rms_dbfs,
rta_config.correlation_reset_token, rta_config.correlation_reset_token,
); );
+117 -16
View File
@@ -9,39 +9,73 @@ pub struct CorrelationMeter {
sample_rate: u32, sample_rate: u32,
response_seconds: f32, response_seconds: f32,
alpha: f64, alpha: f64,
gate_alpha: f64,
silence_threshold_db: f32,
silence_threshold_power: f64,
power_l: f64, power_l: f64,
power_r: f64, power_r: f64,
cross_power: f64, cross_power: f64,
gate_power: f64,
value: f32, value: f32,
negative_peak: f32, negative_peak: f32,
reset_token: u64, reset_token: u64,
} }
impl CorrelationMeter { 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 { let mut meter = Self {
sample_rate: 0, sample_rate: 0,
response_seconds: 0.0, response_seconds: 0.0,
alpha: 1.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_l: 0.0,
power_r: 0.0, power_r: 0.0,
cross_power: 0.0, cross_power: 0.0,
gate_power: 0.0,
value: 0.0, value: 0.0,
negative_peak: 0.0, negative_peak: 0.0,
reset_token, reset_token,
}; };
meter.configure(sample_rate, response_seconds, reset_token); meter.configure(
sample_rate,
response_seconds,
silence_threshold_db,
reset_token,
);
meter 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 sample_rate = sample_rate.max(8_000);
let response_seconds = normalize_response_seconds(response_seconds); 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 { if self.sample_rate != sample_rate || self.response_seconds != response_seconds {
self.sample_rate = sample_rate; self.sample_rate = sample_rate;
self.response_seconds = response_seconds; self.response_seconds = response_seconds;
self.alpha = self.alpha =
1.0 - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp(); 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 { if self.reset_token != reset_token {
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_l += self.alpha * (l * l - self.power_l);
self.power_r += self.alpha * (r * r - self.power_r); self.power_r += self.alpha * (r * r - self.power_r);
self.cross_power += self.alpha * (l * r - self.cross_power); 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 // Below the configured mono RMS threshold the correlation indication
// useful phase measurement and should settle at the neutral position. // settles at its neutral position.
const MIN_POWER: f64 = 1.0e-10; const MIN_POWER: f64 = 1.0e-10;
let denominator = (self.power_l * self.power_r).sqrt(); 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 (self.cross_power / denominator).clamp(-1.0, 1.0) as f32
} else { } else {
0.0 0.0
@@ -80,10 +120,22 @@ impl CorrelationMeter {
} }
pub fn normalize_response_seconds(value: f32) -> f32 { pub fn normalize_response_seconds(value: f32) -> f32 {
if value.is_finite() && value >= 1.75 { if !value.is_finite() || value <= 0.0 {
2.5
} else {
1.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::PI, -1.0),
(std::f32::consts::FRAC_PI_2, 0.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| { run_signal(&mut meter, 5, |index| {
let angle = phase_step * index as f32; let angle = phase_step * index as f32;
(angle.sin(), (angle + phase).sin()) (angle.sin(), (angle + phase).sin())
@@ -127,8 +179,8 @@ mod tests {
#[test] #[test]
fn response_time_and_peak_reset_are_deterministic() { fn response_time_and_peak_reset_are_deterministic() {
let sample_rate = 48_000; let sample_rate = 48_000;
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0); let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
let mut slow = CorrelationMeter::new(sample_rate, 2.5, 0); let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0);
for index in 0..sample_rate as usize { for index in 0..sample_rate as usize {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
fast.process(sample, sample); fast.process(sample, sample);
@@ -141,33 +193,82 @@ mod tests {
(sample, -sample) (sample, -sample)
}); });
assert!(fast.negative_peak() < -0.7); 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); assert_eq!(fast.negative_peak(), 0.0);
} }
#[test] #[test]
fn fast_response_tracks_a_phase_reversal_before_slow_response() { fn fast_response_tracks_a_phase_reversal_before_slow_response() {
let sample_rate = 48_000; let sample_rate = 48_000;
let mut fast = CorrelationMeter::new(sample_rate, 1.0, 0); let mut very_fast = CorrelationMeter::new(sample_rate, 0.5, -75.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 * 5 { for index in 0..sample_rate as usize * 5 {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
very_fast.process(sample, sample);
fast.process(sample, sample); fast.process(sample, sample);
slow.process(sample, sample); slow.process(sample, sample);
} }
for index in 0..sample_rate as usize { for index in 0..sample_rate as usize {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
very_fast.process(sample, -sample);
fast.process(sample, -sample); fast.process(sample, -sample);
slow.process(sample, -sample); slow.process(sample, -sample);
} }
assert!(very_fast.value() < fast.value() - 0.35);
assert!(fast.value() < slow.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!(fast.negative_peak() < 0.0);
assert_eq!(slow.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] #[test]
fn silence_and_single_channel_are_neutral() { 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)); run_signal(&mut meter, 2, |_| (0.0, 0.0));
assert_eq!(meter.value(), 0.0); assert_eq!(meter.value(), 0.0);
assert_eq!(meter.negative_peak(), 0.0); assert_eq!(meter.negative_peak(), 0.0);
+2
View File
@@ -82,6 +82,7 @@ pub struct PhoenixRtaConfig {
pub lufs_i_window_min: u32, pub lufs_i_window_min: u32,
pub lufs_i_norm_enabled: bool, pub lufs_i_norm_enabled: bool,
pub correlation_response_s: f32, pub correlation_response_s: f32,
pub correlation_silence_threshold_rms_dbfs: f32,
pub correlation_reset_token: u64, pub correlation_reset_token: u64,
pub xy_points: u32, pub xy_points: u32,
} }
@@ -118,6 +119,7 @@ impl Default for PhoenixRtaConfig {
lufs_i_window_min: 4, lufs_i_window_min: 4,
lufs_i_norm_enabled: false, lufs_i_norm_enabled: false,
correlation_response_s: 1.0, correlation_response_s: 1.0,
correlation_silence_threshold_rms_dbfs: -75.0,
correlation_reset_token: 0, correlation_reset_token: 0,
xy_points: 1024, xy_points: 1024,
} }
+4
View File
@@ -487,6 +487,10 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled; config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
config.correlation_response_s = config.correlation_response_s =
crate::correlation::normalize_response_seconds(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 { config.xy_points = match config.xy_points {
128 | 256 | 512 | 1024 | 2048 => config.xy_points, 128 | 256 | 512 | 1024 | 2048 => config.xy_points,
n if n < 192 => 128, n if n < 192 => 128,
+1 -1
View File
@@ -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>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:</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>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>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>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> <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
View File
@@ -1,7 +1,12 @@
// core/audio.js — Phoenix Audio-Init, Metrics-WS, Waveform/Spectrogram buffers // 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. // 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 { decodePhoenixSpectroBuffer, decodePhoenixVisualsBuffer } from './binary_protocol.js';
import { getRtwCenters } from './rtw_centers.js'; import { getRtwCenters } from './rtw_centers.js';
@@ -943,7 +948,10 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
ppmEbuDecayDbPerS: 24 / 2.8, ppmEbuDecayDbPerS: 24 / 2.8,
lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4, lufsIWindowMin: Number.isFinite(CONFIG.LUFS_I_WINDOW_MIN) ? CONFIG.LUFS_I_WINDOW_MIN : 4,
lufsINormEnabled: !!CONFIG.LUFS_I_NORM_ENABLED, 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)), correlationResetToken: Math.max(0, Math.floor(Number(CONFIG.CORR_RESET_TOKEN) || 0)),
xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS)) xyPoints: [128, 256, 512, 1024, 2048].includes(Number(CONFIG.XY_POINTS))
? Number(CONFIG.XY_POINTS) ? Number(CONFIG.XY_POINTS)
+9 -1
View File
@@ -1,5 +1,13 @@
// core/config.js — zentrale CONFIG + Presets + Persistenz // 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) { function isLoopbackHost(host) {
const h = String(host || '').trim().toLowerCase(); const h = String(host || '').trim().toLowerCase();
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; 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; 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.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(); const corrNegativeMarkerMode = String(CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory').toLowerCase();
CONFIG.CORR_NEGATIVE_MARKER_MODE = ['memory', 'hold', 'off'].includes(corrNegativeMarkerMode) CONFIG.CORR_NEGATIVE_MARKER_MODE = ['memory', 'hold', 'off'].includes(corrNegativeMarkerMode)
? corrNegativeMarkerMode ? corrNegativeMarkerMode
+2 -1
View File
@@ -521,7 +521,7 @@
<input id="opt_xySilenceGate" type="checkbox" checked> <input id="opt_xySilenceGate" type="checkbox" checked>
Silence-Gate aktivieren (RMS-basiert) Silence-Gate aktivieren (RMS-basiert)
</label> </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>
<div class="opt"> <div class="opt">
<label>Silence-Threshold [dBFS RMS]</label> <label>Silence-Threshold [dBFS RMS]</label>
@@ -530,6 +530,7 @@
</div> </div>
<div class="opt"><label>Korrelation Ansprechzeit</label> <div class="opt"><label>Korrelation Ansprechzeit</label>
<select id="opt_corrResponse"> <select id="opt_corrResponse">
<option value="0.5">0,5 s</option>
<option value="1">1,0 s</option> <option value="1">1,0 s</option>
<option value="2.5">2,5 s</option> <option value="2.5">2,5 s</option>
</select> </select>
+5 -4
View File
@@ -1,7 +1,7 @@
// ui/options.js — bindet das Options-Panel an CONFIG (lesen/schreiben), Presets, Export/Import // 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'. // 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 { clamp, clampPow2 } from '../core/utils.js';
import { bindOptionsViewportAssist } from './options_viewport.js'; import { bindOptionsViewportAssist } from './options_viewport.js';
import { setupLazyChangelog } from './changelog.js'; import { setupLazyChangelog } from './changelog.js';
@@ -184,7 +184,7 @@ function syncUI() {
['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL], ['opt_rmsColNorm', CONFIG.RMS_COLOR_NORMAL],
['opt_rmsColWarn', CONFIG.RMS_COLOR_WARN], ['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_corrNegativeMarkerMode', CONFIG.CORR_NEGATIVE_MARKER_MODE || 'memory'],
['opt_xyPoints', String(CONFIG.XY_POINTS)], ['opt_xyPoints', String(CONFIG.XY_POINTS)],
['opt_xyStyle', CONFIG.XY_STYLE], ['opt_xyStyle', CONFIG.XY_STYLE],
@@ -457,7 +457,8 @@ function wireHandlers(env) {
if (!el) return; if (!el) return;
const notifyRta = /^opt_rta/i.test(id) const notifyRta = /^opt_rta/i.test(id)
|| id === 'opt_lufsIWindowMin' || id === 'opt_lufsIWindowMin'
|| id === 'opt_lufsINorm'; || id === 'opt_lufsINorm'
|| id === 'opt_xySilenceThr';
const inputType = String(el.type || '').toLowerCase(); const inputType = String(el.type || '').toLowerCase();
const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color'; const commitOnInput = isCheckbox || inputType === 'range' || inputType === 'color';
@@ -813,7 +814,7 @@ function wireHandlers(env) {
}); });
h('opt_corrResponse', v => { 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 (_) {} try { env?.notifyRtaConfig?.(); } catch (_) {}
return String(CONFIG.CORR_RESPONSE_S); return String(CONFIG.CORR_RESPONSE_S);
}); });
+50 -12
View File
@@ -29,6 +29,8 @@ const PHASE_RADIUS_TAU_S = 0.085;
const PHASE_TRAIL_FADE_MS = 2000; const PHASE_TRAIL_FADE_MS = 2000;
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45; const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
const PHASE_TRAIL_MIN_DIST_PX = 1.5; 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 = [ const PHASE_SECTORS = [
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' }, { startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' }, { 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); const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) { if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
const length = radiusNorm * wheel.radius; const phase = wrapAngle(state.smoothPhase);
const x = wheel.cx + Math.cos(state.smoothPhase) * length; const point = createPhaseTrailPoint(wheel, phase, radiusNorm, now);
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
const color = trailColorForAngle(state.smoothPhase);
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null; const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
if (!last) { if (!last) {
state.phaseTrail.push({ x, y, t: now, color }); state.phaseTrail.push(point);
} else { } else {
const dt = now - last.t; const dt = now - last.t;
const dx = x - last.x; const dx = point.x - last.x;
const dy = y - last.y; const dy = point.y - last.y;
const dist2 = dx * dx + dy * dy; 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) { if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== point.color) {
state.phaseTrail.push({ x, y, t: now, color }); appendPhaseTrailSegment(state.phaseTrail, wheel, last, phase, radiusNorm, now);
} else { } else {
last.x = x; last.x = point.x;
last.y = y; last.y = point.y;
last.t = now; last.t = now;
last.color = color; last.color = point.color;
last.phase = phase;
last.radiusNorm = radiusNorm;
} }
} }
} }
trimPhaseTrail(state, CONFIG, now); 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) { function trimPhaseTrail(state, CONFIG, now) {
if (!state.phaseTrail) state.phaseTrail = []; if (!state.phaseTrail) state.phaseTrail = [];
if (!CONFIG?.PHASE_TRAIL_ENABLED) { if (!CONFIG?.PHASE_TRAIL_ENABLED) {
@@ -557,6 +592,9 @@ function trimPhaseTrail(state, CONFIG, now) {
} }
} }
state.phaseTrail.length = write; 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) { function drawPhaseTrail(g, wheel, state, CONFIG, now) {