Add W12 Opus/network quality profiles
CI / check (push) Failing after 25s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

Add a small, named codec-policy picker (Low latency / Balanced / Bad
network) instead of exposing raw Opus knobs. The profile->params mapping
is a pure function (`codec::opus_impl::opus_params`) for unit testing;
profiles tune bitrate, in-band FEC, expected packet-loss, and DTX.

- config: `AudioProfile` enum (serde + Display + ALL + u8 round-trip),
  persisted `audio_profile` field (default Balanced).
- codec: `OpusParams` + pure `opus_params()` + `OpusEncoder::apply_params`
  / `apply_profile`.
- core: new `SetAudioProfile` command (Reliable, no coalesce); a shared
  `AtomicU8` lets the capture thread re-tune the live encoder on a
  mid-call switch and read it at each new call's encoder creation.
- app: Settings "Connection quality" picker in the Audio tab, startup
  config-sync send, and a one-line hint per profile.

No wire-format change (GOSSIP/audio planes untouched). 499 lib tests
green (config + codec mapping/apply tests added), clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 16:13:26 -04:00
co-authored by Claude Opus 4.8
parent 551767f9f5
commit d92d0f6f6b
5 changed files with 271 additions and 4 deletions
+33 -1
View File
@@ -4,7 +4,7 @@ use crate::audio::clip_player::{
}; };
use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings}; use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings};
use crate::audio::{AudioDevice, enumerate_audio_devices}; use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; use crate::config::{AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout};
use crate::core::{ use crate::core::{
CoreController, CoreController,
messages::{CoreCommand, UiEvent}, messages::{CoreCommand, UiEvent},
@@ -377,6 +377,7 @@ pub enum AppMessage {
/// immediately but does not persist (saved once on release via NoiseGateChanged). /// immediately but does not persist (saved once on release via NoiseGateChanged).
NoiseGateDragging(f32), NoiseGateDragging(f32),
NetworkModeSelected(NetworkMode), NetworkModeSelected(NetworkMode),
AudioProfileSelected(AudioProfile),
RecordingModeSelected(RecordingMode), RecordingModeSelected(RecordingMode),
/// Choose the friends presence posture (W7): invisible / normal / discoverable. /// Choose the friends presence posture (W7): invisible / normal / discoverable.
PresenceModeSelected(PresenceMode), PresenceModeSelected(PresenceMode),
@@ -919,6 +920,7 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume)); let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume)); let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode)); let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
let _ = controller.send(CoreCommand::SetAudioProfile(config.audio_profile));
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode)); let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone())); let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode)); let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
@@ -2050,6 +2052,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Applied on the next join, since the endpoint is rebuilt then. // Applied on the next join, since the endpoint is rebuilt then.
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode)); let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
} }
AppMessage::AudioProfileSelected(profile) => {
state.config.audio_profile = profile;
state.config.save();
// Applies live to the running encoder, and to the next call.
let _ = state.controller.send(CoreCommand::SetAudioProfile(profile));
}
AppMessage::RecordingModeSelected(mode) => { AppMessage::RecordingModeSelected(mode) => {
state.config.recording_mode = mode; state.config.recording_mode = mode;
state.config.save(); state.config.save();
@@ -3123,6 +3131,19 @@ fn network_mode_hint(mode: NetworkMode) -> &'static str {
} }
} }
/// One-line explanation of an audio/network profile for the settings picker (W12).
fn audio_profile_hint(profile: AudioProfile) -> &'static str {
match profile {
AudioProfile::LowLatency => {
"Lowest delay, no loss recovery. Best on a clean LAN or wired link."
}
AudioProfile::Balanced => "Default: voice quality with light loss recovery.",
AudioProfile::BadNetwork => {
"Most resilient on a lossy/congested link: extra loss recovery, lower bitrate."
}
}
}
/// One-line explanation of a recording mode for the settings picker. /// One-line explanation of a recording mode for the settings picker.
fn recording_mode_hint(mode: RecordingMode) -> &'static str { fn recording_mode_hint(mode: RecordingMode) -> &'static str {
match mode { match mode {
@@ -4981,6 +5002,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
control control
}, },
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap),
section_header("Connection quality"),
column![
pick_list(
&AudioProfile::ALL[..],
Some(state.config.audio_profile),
AppMessage::AudioProfileSelected,
).width(iced::Length::Fill),
text(audio_profile_hint(state.config.audio_profile)).size(11).color(color_subtext),
text("Applies immediately, even mid-call.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
] ]
.spacing(10) .spacing(10)
.width(iced::Length::Fill) .width(iced::Length::Fill)
+109 -1
View File
@@ -1,5 +1,49 @@
use crate::codec::{AudioDecoder, AudioEncoder, CodecError}; use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
use opus::{Application, Channels, Decoder, Encoder}; use crate::config::AudioProfile;
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain
/// data, so the profile→params mapping ([`opus_params`]) stays a pure,
/// unit-testable function (W12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpusParams {
/// Target bitrate in bits/sec.
pub bitrate: i32,
/// Enable in-band forward error correction (loss redundancy in the bitstream).
pub inband_fec: bool,
/// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds.
pub packet_loss_perc: i32,
/// Discontinuous transmission: stop sending during silence to save bandwidth.
pub dtx: bool,
}
/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam.
///
/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC
/// redundancy is carried inside the same bitstream, so trimming the base bitrate
/// leaves headroom for the redundancy on a congested link.
pub fn opus_params(profile: AudioProfile) -> OpusParams {
match profile {
AudioProfile::LowLatency => OpusParams {
bitrate: 24_000,
inband_fec: false,
packet_loss_perc: 0,
dtx: false,
},
AudioProfile::Balanced => OpusParams {
bitrate: 32_000,
inband_fec: true,
packet_loss_perc: 10,
dtx: false,
},
AudioProfile::BadNetwork => OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 25,
dtx: true,
},
}
}
pub struct OpusEncoder { pub struct OpusEncoder {
encoder: Encoder, encoder: Encoder,
@@ -17,6 +61,29 @@ impl OpusEncoder {
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?; .map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
Ok(Self { encoder }) Ok(Self { encoder })
} }
/// Apply concrete codec parameters to the live encoder. Safe to call between
/// frames, so the user can switch profile mid-call.
pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> {
self.encoder
.set_bitrate(Bitrate::Bits(params.bitrate))
.map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?;
self.encoder
.set_inband_fec(params.inband_fec)
.map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?;
self.encoder
.set_packet_loss_perc(params.packet_loss_perc)
.map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?;
self.encoder
.set_dtx(params.dtx)
.map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?;
Ok(())
}
/// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`).
pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> {
self.apply_params(&opus_params(profile))
}
} }
impl AudioEncoder for OpusEncoder { impl AudioEncoder for OpusEncoder {
@@ -101,6 +168,47 @@ impl AudioDecoder for OpusDecoder {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_opus_params_mapping() {
let low = opus_params(AudioProfile::LowLatency);
let bal = opus_params(AudioProfile::Balanced);
let bad = opus_params(AudioProfile::BadNetwork);
// LowLatency has no loss redundancy; the other two do.
assert!(!low.inband_fec);
assert_eq!(low.packet_loss_perc, 0);
assert!(bal.inband_fec);
assert!(bad.inband_fec);
// BadNetwork is the only profile that enables DTX, and it expects the
// heaviest loss.
assert!(bad.dtx);
assert!(!low.dtx && !bal.dtx);
assert!(bad.packet_loss_perc > bal.packet_loss_perc);
// BadNetwork trims base bitrate to make room for FEC redundancy.
assert!(bad.bitrate < bal.bitrate);
// All bitrates are sane positive voice rates.
for p in [low, bal, bad] {
assert!(p.bitrate > 0 && p.bitrate <= 64_000);
assert!((0..=100).contains(&p.packet_loss_perc));
}
}
#[test]
fn test_apply_profile_sets_bitrate() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
// Every profile applies cleanly to a real encoder...
for profile in AudioProfile::ALL {
encoder.apply_profile(profile).unwrap();
}
// ...and the last-applied bitrate is reflected by the encoder.
encoder.apply_profile(AudioProfile::Balanced).unwrap();
let want = opus_params(AudioProfile::Balanced).bitrate;
assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want));
}
#[test] #[test]
fn test_round_trip() { fn test_round_trip() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap(); let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
+91
View File
@@ -93,6 +93,60 @@ impl std::fmt::Display for RecordingMode {
} }
} }
/// Named Opus encoder / network-resilience policy (W12). The user picks a
/// profile instead of raw codec knobs; the concrete libopus parameters live in
/// `codec::opus_impl::opus_params`. Applies live to the running encoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioProfile {
/// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a
/// clean LAN / low-loss link where added latency matters more than loss.
LowLatency,
/// Sensible default: voice bitrate with in-band FEC for light packet loss.
#[default]
Balanced,
/// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy
/// loss plus DTX, at a lower bitrate to leave headroom for the redundancy.
BadNetwork,
}
impl AudioProfile {
/// All variants, in picker display order.
pub const ALL: [AudioProfile; 3] = [
AudioProfile::LowLatency,
AudioProfile::Balanced,
AudioProfile::BadNetwork,
];
/// Compact discriminant for handing the profile to the capture thread via an
/// atomic. Pairs with [`AudioProfile::from_u8`].
pub fn as_u8(self) -> u8 {
match self {
AudioProfile::LowLatency => 0,
AudioProfile::Balanced => 1,
AudioProfile::BadNetwork => 2,
}
}
/// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default.
pub fn from_u8(v: u8) -> AudioProfile {
match v {
0 => AudioProfile::LowLatency,
2 => AudioProfile::BadNetwork,
_ => AudioProfile::Balanced,
}
}
}
impl std::fmt::Display for AudioProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
AudioProfile::LowLatency => "Low latency",
AudioProfile::Balanced => "Balanced",
AudioProfile::BadNetwork => "Bad network",
})
}
}
impl std::fmt::Display for RoomLayout { impl std::fmt::Display for RoomLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self { f.write_str(match self {
@@ -199,6 +253,10 @@ pub struct AppConfig {
pub clip_volume_universal: bool, pub clip_volume_universal: bool,
#[serde(default)] #[serde(default)]
pub network_mode: NetworkMode, pub network_mode: NetworkMode,
/// Opus encoder / network-resilience profile (W12). Applies live to the
/// running encoder; default `Balanced`.
#[serde(default)]
pub audio_profile: AudioProfile,
/// Presence posture for the friends idle listener (W7): invisible / normal / /// Presence posture for the friends idle listener (W7): invisible / normal /
/// discoverable. Default `Normal` = answer friends only, no DNS beacon. /// discoverable. Default `Normal` = answer friends only, no DNS beacon.
#[serde(default)] #[serde(default)]
@@ -380,6 +438,7 @@ impl Default for AppConfig {
show_player_bar: true, show_player_bar: true,
clip_volume_universal: true, clip_volume_universal: true,
network_mode: NetworkMode::default(), network_mode: NetworkMode::default(),
audio_profile: AudioProfile::default(),
presence_mode: crate::presence::PresenceMode::default(), presence_mode: crate::presence::PresenceMode::default(),
echo_cancellation_enabled: false, echo_cancellation_enabled: false,
notifications_enabled: true, notifications_enabled: true,
@@ -1019,6 +1078,38 @@ mod tests {
assert_ne!(display_0, display_2); assert_ne!(display_0, display_2);
} }
#[test]
fn test_audio_profile() {
// Default is Balanced.
assert_eq!(AudioProfile::default(), AudioProfile::Balanced);
// ALL holds the three variants.
assert_eq!(AudioProfile::ALL.len(), 3);
assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency));
assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced));
assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork));
// as_u8 / from_u8 round-trip every variant, and unknown bytes fall back
// to the default rather than panicking.
for p in AudioProfile::ALL {
assert_eq!(AudioProfile::from_u8(p.as_u8()), p);
}
assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced);
// serde round-trips, and Display strings are non-empty + distinct.
let mut labels = Vec::new();
for p in AudioProfile::ALL {
let s = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<AudioProfile>(&s).unwrap(), p);
let label = p.to_string();
assert!(!label.is_empty());
labels.push(label);
}
labels.sort();
labels.dedup();
assert_eq!(labels.len(), 3);
}
#[test] #[test]
fn test_unknown_field_tolerance() { fn test_unknown_field_tolerance() {
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize. // Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
+8 -1
View File
@@ -1,4 +1,4 @@
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{AudioProfile, NetworkMode, RecordingMode};
use crate::friends::Friend; use crate::friends::Friend;
use crate::network::PeerState; use crate::network::PeerState;
use crate::presence::{FriendPresence, PresenceMode}; use crate::presence::{FriendPresence, PresenceMode};
@@ -56,6 +56,10 @@ pub enum CoreCommand {
/// Set the relay/discovery posture. Takes effect on the next room join, /// Set the relay/discovery posture. Takes effect on the next room join,
/// since the endpoint is (re)built then. /// since the endpoint is (re)built then.
SetNetworkMode(NetworkMode), SetNetworkMode(NetworkMode),
/// Set the Opus encoder / network-resilience profile (W12). Applies live to
/// the running capture encoder, and to the next call's encoder. Sent at
/// startup from config and whenever the user changes it.
SetAudioProfile(AudioProfile),
/// Start/stop recording the call to a local WAV (your mic + the incoming /// Start/stop recording the call to a local WAV (your mic + the incoming
/// mix). No-op start if already recording / not in a call. /// mix). No-op start if already recording / not in a call.
SetRecording(bool), SetRecording(bool),
@@ -212,6 +216,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
input_device: _, input_device: _,
} }
| CoreCommand::SetNetworkMode(_) | CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_) | CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_) | CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_) | CoreCommand::SendChat(_)
@@ -292,6 +297,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
input_device: _, input_device: _,
} }
| CoreCommand::SetNetworkMode(_) | CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_) | CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_) | CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_) | CoreCommand::SendChat(_)
@@ -574,6 +580,7 @@ mod tests {
}, },
CoreCommand::SetPeerMuted(peer, true), CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal), CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
CoreCommand::SendChat("hello".to_string()), CoreCommand::SendChat("hello".to_string()),
]; ];
+30 -1
View File
@@ -17,7 +17,7 @@ use crate::network::{
}; };
use crate::audio::multitrack::MultitrackRecorder; use crate::audio::multitrack::MultitrackRecorder;
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{AudioProfile, NetworkMode, RecordingMode};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use iroh::{ use iroh::{
Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router, Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router,
@@ -1162,6 +1162,12 @@ async fn run_core_loop(
// App-internal capture/playback gains (f32 bits), live-read by the audio loops. // App-internal capture/playback gains (f32 bits), live-read by the audio loops.
let input_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); let input_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
let output_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits())); let output_gain = Arc::new(std::sync::atomic::AtomicU32::new(1.0f32.to_bits()));
// Opus encoder profile (W12) as a discriminant, live-read by the capture
// thread so a mid-call profile switch re-tunes the running encoder. Set from
// config via the GUI's startup `SetAudioProfile`; defaults to Balanced.
let audio_profile = Arc::new(std::sync::atomic::AtomicU8::new(
AudioProfile::default().as_u8(),
));
// Call recording: an optional live recorder (mic FIFO + WAV writer), shared // Call recording: an optional live recorder (mic FIFO + WAV writer), shared
// by the capture thread (pushes mic) and the mixer task (writes mix frames). // by the capture thread (pushes mic) and the mixer task (writes mix frames).
// `is_recording` is a fast-path gate so the audio loops only take the lock // `is_recording` is a fast-path gate so the audio loops only take the lock
@@ -1740,6 +1746,7 @@ async fn run_core_loop(
let is_recording_capture = is_recording.clone(); let is_recording_capture = is_recording.clone();
let multitrack_capture = multitrack.clone(); let multitrack_capture = multitrack.clone();
let is_multitrack_capture = is_multitrack.clone(); let is_multitrack_capture = is_multitrack.clone();
let audio_profile_capture = audio_profile.clone();
let capture_thread = std::thread::spawn(move || { let capture_thread = std::thread::spawn(move || {
use opus::{Application, Channels}; use opus::{Application, Channels};
@@ -1751,6 +1758,13 @@ async fn run_core_loop(
return; return;
} }
}; };
// Tune the encoder to the configured profile (W12), then track
// the live discriminant so a mid-call switch re-applies it.
let mut current_profile =
AudioProfile::from_u8(audio_profile_capture.load(Ordering::Relaxed));
if let Err(e) = encoder.apply_profile(current_profile) {
crate::log_msg(&format!("Opus profile apply failed: {:?}", e));
}
// Per-sender packet sequence number, prepended to every frame so // Per-sender packet sequence number, prepended to every frame so
// receivers can reorder and conceal loss. Wraps after ~years. // receivers can reorder and conceal loss. Wraps after ~years.
let mut seq: u32 = 0; let mut seq: u32 = 0;
@@ -1763,6 +1777,14 @@ async fn run_core_loop(
let mut mic_meter = MicLevelMeter::new(); let mut mic_meter = MicLevelMeter::new();
while let Ok(mut pcm) = capture_rx.recv() { while let Ok(mut pcm) = capture_rx.recv() {
// Re-tune the encoder if the user switched profile mid-call.
// Cheap atomic load per frame; only reconfigures on change.
let want =
AudioProfile::from_u8(audio_profile_capture.load(Ordering::Relaxed));
if want != current_profile && encoder.apply_profile(want).is_ok() {
current_profile = want;
}
// Apply the input gain first so the meter, gate, and what we // Apply the input gain first so the meter, gate, and what we
// transmit all reflect the same (gained) signal. // transmit all reflect the same (gained) signal.
apply_volume( apply_volume(
@@ -2682,6 +2704,13 @@ async fn run_core_loop(
} }
} }
CoreCommand::SetAudioProfile(profile) => {
// Publish the new profile to the capture thread (W12). It picks up
// the change on its next frame and re-tunes the live encoder; a
// call that starts later reads the same atomic at encoder creation.
audio_profile.store(profile.as_u8(), Ordering::Relaxed);
}
CoreCommand::RegenerateIdentity => { CoreCommand::RegenerateIdentity => {
// Mint + persist a fresh identity, discarding the old one. The // Mint + persist a fresh identity, discarding the old one. The
// persistent endpoint is rebuilt with the new key (now if idle, else // persistent endpoint is rebuilt with the new key (now if idle, else