RemiFabre commited on
Commit
35d5e6c
Β·
1 Parent(s): c0e8ee8

Remove denoise feature from UI and backend

Browse files

The motor-noise denoising feature didn't work well and added significant
complexity. Removed the /api/moves/{move_id}/denoise endpoint, the
_perform_denoise and _apply_noise_profile methods, the denoise toggle
from the experimental panel, and the denoise button from the move list.

Existing .denoised.wav files are still recognized and used for playback
(backward compatibility).

marionette/main.py CHANGED
@@ -67,7 +67,7 @@ HF_DATASETS_API_URL = "https://huggingface.co/api/datasets"
67
  DATASET_DATA_SUBDIR = "data"
68
  NOISE_SUFFIX = ".noise.wav"
69
  DENOISED_SUFFIX = ".denoised.wav"
70
- DEFAULT_FEATURES = {"denoise": False, "motion_models": False}
71
 
72
  logger = logging.getLogger(__name__)
73
 
@@ -183,7 +183,6 @@ class UpdateMotionModelPayload(BaseModel):
183
 
184
 
185
  class UpdateExperimentsPayload(BaseModel):
186
- denoise: bool | None = Field(default=None, description="Enable or disable denoise feature")
187
  motion_models: bool | None = Field(default=None, description="Enable or disable motion model feature")
188
  duration_seconds: float | None = Field(default=None, gt=0.5, le=300.0)
189
 
@@ -215,7 +214,6 @@ class Marionette(ReachyMiniApp):
215
  self._recordings: dict[str, RecordingMetadata] = {}
216
  self._pending_recording: RecordingRequest | None = None
217
  self._pending_playback: str | None = None
218
- self._pending_denoise: str | None = None
219
  self._uploaded_audio: dict[str, Path] = {} # upload_id -> temp file path
220
  self._playback_cancel_event = threading.Event()
221
  self._recording_cancel_event = threading.Event()
@@ -331,7 +329,6 @@ class Marionette(ReachyMiniApp):
331
  self._mode not in {"idle", "queued"}
332
  or self._pending_recording
333
  or self._pending_playback
334
- or self._pending_denoise
335
  ):
336
  raise HTTPException(status_code=409, detail="Robot is busy.")
337
  self._preferred_duration = float(request.duration)
@@ -359,7 +356,6 @@ class Marionette(ReachyMiniApp):
359
  self._mode != "idle"
360
  or self._pending_recording
361
  or self._pending_playback
362
- or self._pending_denoise
363
  ):
364
  raise HTTPException(status_code=409, detail="Robot is busy.")
365
  self._pending_playback = payload.move_id
@@ -390,27 +386,6 @@ class Marionette(ReachyMiniApp):
390
  self._refresh_recordings()
391
  return {"status": "deleted", "move_id": move_id}
392
 
393
- @self.settings_app.post("/api/moves/{move_id}/denoise")
394
- def denoise_move(move_id: str) -> dict[str, Any]:
395
- if not self._features.get("denoise"):
396
- raise HTTPException(status_code=400, detail="Denoise feature is disabled.")
397
- if move_id not in self._recordings:
398
- raise HTTPException(status_code=404, detail=f"Move {move_id} not found.")
399
- if not self._audio_available:
400
- raise HTTPException(status_code=400, detail="Audio backend unavailable for denoise.")
401
- with self._state_lock:
402
- if (
403
- self._mode not in {"idle", "queued"}
404
- or self._pending_recording
405
- or self._pending_playback
406
- or self._pending_denoise
407
- ):
408
- raise HTTPException(status_code=409, detail="Robot is busy.")
409
- self._pending_denoise = move_id
410
- self._mode = "queued"
411
- self._message = f"Denoise queued for {move_id}"
412
- return {"status": "queued", "move_id": move_id}
413
-
414
  @self.settings_app.get("/api/datasets")
415
  def list_datasets() -> dict[str, Any]:
416
  return self._datasets_payload()
@@ -422,7 +397,6 @@ class Marionette(ReachyMiniApp):
422
  self._mode not in {"idle", "queued"}
423
  or self._pending_recording
424
  or self._pending_playback
425
- or self._pending_denoise
426
  ):
427
  raise HTTPException(status_code=409, detail="Robot is busy.")
428
  entry = self._create_dataset(payload.name, payload.label)
@@ -437,7 +411,6 @@ class Marionette(ReachyMiniApp):
437
  self._mode not in {"idle", "queued"}
438
  or self._pending_recording
439
  or self._pending_playback
440
- or self._pending_denoise
441
  ):
442
  raise HTTPException(status_code=409, detail="Robot is busy.")
443
  self._select_dataset(payload.dataset_id)
@@ -451,7 +424,6 @@ class Marionette(ReachyMiniApp):
451
  self._mode not in {"idle", "queued"}
452
  or self._pending_recording
453
  or self._pending_playback
454
- or self._pending_denoise
455
  ):
456
  raise HTTPException(status_code=409, detail="Robot is busy.")
457
  result = self._sync_dataset(payload.hf_username, payload.move_ids, payload.dataset_slug)
@@ -464,7 +436,6 @@ class Marionette(ReachyMiniApp):
464
  self._mode not in {"idle", "queued"}
465
  or self._pending_recording
466
  or self._pending_playback
467
- or self._pending_denoise
468
  ):
469
  raise HTTPException(status_code=409, detail="Robot is busy.")
470
  self._set_dataset_root(payload.path)
@@ -598,10 +569,6 @@ class Marionette(ReachyMiniApp):
598
  job_type = "play"
599
  payload = self._pending_playback
600
  self._pending_playback = None
601
- elif self._pending_denoise is not None:
602
- job_type = "denoise"
603
- payload = self._pending_denoise
604
- self._pending_denoise = None
605
 
606
  if job_type == "record" and isinstance(payload, RecordingRequest):
607
  self._perform_recording(reachy_mini, stop_event, payload)
@@ -610,9 +577,6 @@ class Marionette(ReachyMiniApp):
610
  self._perform_playback(reachy_mini, payload)
611
  self._set_idle_state()
612
  self._align_head_and_release(reachy_mini)
613
- elif job_type == "denoise" and isinstance(payload, str):
614
- self._perform_denoise(reachy_mini, payload)
615
- self._set_idle_state()
616
  else:
617
  stop_event.wait(0.05)
618
  finally:
@@ -716,9 +680,8 @@ class Marionette(ReachyMiniApp):
716
  self._set_state(mode="error", message=f"Move {move_id} missing.", active_move=None)
717
  return
718
 
719
- prefer_denoised = bool(self._features.get("denoise") and meta.has_denoised_audio)
720
  try:
721
- move = self._load_move(meta.json_path, prefer_denoised=prefer_denoised)
722
  except Exception as exc: # pragma: no cover - filesystem failure
723
  self._set_state(
724
  mode="error",
@@ -765,145 +728,6 @@ class Marionette(ReachyMiniApp):
765
  active_move=None,
766
  )
767
 
768
- def _perform_denoise(self, reachy_mini: ReachyMini, move_id: str) -> None:
769
- if not self._audio_available:
770
- self._set_state(
771
- mode="error",
772
- message="Audio backend unavailable for motor noise capture.",
773
- active_move=None,
774
- )
775
- return
776
-
777
- meta = self._recordings.get(move_id)
778
- if not meta:
779
- self._set_state(mode="error", message=f"Move {move_id} missing.", active_move=None)
780
- return
781
- if not meta.has_audio:
782
- self._set_state(
783
- mode="error",
784
- message=f"No audio track to denoise for {meta.label}.",
785
- active_move=None,
786
- )
787
- return
788
-
789
- try:
790
- move = self._load_move(meta.json_path, prefer_denoised=False)
791
- except Exception as exc: # pragma: no cover - filesystem failure
792
- self._set_state(
793
- mode="error",
794
- message=f"Failed to load {move_id}: {exc}",
795
- active_move=None,
796
- )
797
- return
798
-
799
- audio_frames: list[np.ndarray] = []
800
- audio_samplerate = AUDIO_SAMPLE_RATE
801
- audio_active = False
802
-
803
- def _pull_audio_frames() -> None:
804
- if not audio_active:
805
- return
806
- while True:
807
- sample = reachy_mini.media.get_audio_sample()
808
- if sample is None:
809
- break
810
- audio_frames.append(sample)
811
-
812
- try:
813
- reachy_mini.media.start_recording()
814
- reported = reachy_mini.media.get_input_audio_samplerate()
815
- if reported:
816
- audio_samplerate = int(reported)
817
- audio_active = True
818
- except Exception as exc:
819
- self._set_state(
820
- mode="error",
821
- message=f"Unable to start audio capture: {exc}",
822
- active_move=None,
823
- )
824
- return
825
-
826
- self._set_state(
827
- mode="denoise",
828
- message=f"Capturing motor noise for {meta.label}",
829
- active_move=meta.move_id,
830
- )
831
-
832
- success = False
833
- self._safe_enable_motors(reachy_mini)
834
- try:
835
- success = self._stream_playback(
836
- reachy_mini,
837
- move,
838
- play_sound=False,
839
- sample_hook=_pull_audio_frames,
840
- )
841
- finally:
842
- self._goto_sleep_and_release(reachy_mini)
843
- if audio_active:
844
- try:
845
- reachy_mini.media.stop_recording()
846
- except Exception:
847
- pass
848
- _pull_audio_frames()
849
-
850
- if not success:
851
- self._set_state(
852
- mode="error",
853
- message=f"Motor noise capture canceled for {meta.label}",
854
- active_move=None,
855
- )
856
- return
857
-
858
- if not audio_frames:
859
- self._set_state(
860
- mode="error",
861
- message="No audio captured during motor noise replay.",
862
- active_move=None,
863
- )
864
- return
865
-
866
- noise_path = meta.json_path.with_suffix(NOISE_SUFFIX)
867
- denoised_path = meta.json_path.with_suffix(DENOISED_SUFFIX)
868
- audio_data = np.concatenate(audio_frames, axis=0)
869
- sf.write(str(noise_path), audio_data, audio_samplerate)
870
- try:
871
- self._apply_noise_profile(meta.json_path, noise_path, denoised_path)
872
- except Exception as exc:
873
- logger.warning("Noise subtraction failed for %s: %s", meta.move_id, exc)
874
- self._refresh_recordings()
875
- self._set_state(
876
- mode="idle",
877
- message=f"Denoised {meta.label}",
878
- active_move=None,
879
- )
880
-
881
- def _apply_noise_profile(self, json_path: Path, noise_path: Path, target_path: Path) -> None:
882
- original_audio = json_path.with_suffix(".wav")
883
- if not original_audio.exists():
884
- logger.info("No original audio present for %s; skipping denoise.", json_path)
885
- return
886
- original, orig_rate = sf.read(str(original_audio))
887
- noise, noise_rate = sf.read(str(noise_path))
888
- if orig_rate != noise_rate:
889
- logger.warning(
890
- "Noise sample rate mismatch for %s (original=%sHz, noise=%sHz).",
891
- json_path.name,
892
- orig_rate,
893
- noise_rate,
894
- )
895
- return
896
- overlap = min(len(original), len(noise))
897
- if overlap == 0:
898
- logger.warning("Noise sample empty for %s", json_path.name)
899
- return
900
- denoised = np.array(original, copy=True)
901
- denoised[:overlap] = denoised[:overlap] - noise[:overlap]
902
- denoised = np.clip(denoised, -1.0, 1.0)
903
- if len(original) > overlap:
904
- denoised[overlap:] = original[overlap:]
905
- sf.write(str(target_path), denoised, orig_rate)
906
-
907
  # ──────── Capture helpers ───────────────────────────────────────────
908
  def _capture_motion(
909
  self,
@@ -1814,7 +1638,6 @@ class Marionette(ReachyMiniApp):
1814
  "recording_stats": self._recording_stats,
1815
  "pending_recording": self._pending_recording.label if self._pending_recording else None,
1816
  "pending_playback": self._pending_playback,
1817
- "pending_denoise": self._pending_denoise,
1818
  "moves": moves,
1819
  "config": {
1820
  "default_duration": DEFAULT_DURATION,
@@ -1826,7 +1649,6 @@ class Marionette(ReachyMiniApp):
1826
  "dataset_root_path": str(self._dataset_root),
1827
  "features": dict(self._features),
1828
  "feature_support": {
1829
- "denoise": self._audio_available,
1830
  "motion_models": True,
1831
  },
1832
  "motion_models": self._motion_model_registry.to_payload()
 
67
  DATASET_DATA_SUBDIR = "data"
68
  NOISE_SUFFIX = ".noise.wav"
69
  DENOISED_SUFFIX = ".denoised.wav"
70
+ DEFAULT_FEATURES = {"motion_models": False}
71
 
72
  logger = logging.getLogger(__name__)
73
 
 
183
 
184
 
185
  class UpdateExperimentsPayload(BaseModel):
 
186
  motion_models: bool | None = Field(default=None, description="Enable or disable motion model feature")
187
  duration_seconds: float | None = Field(default=None, gt=0.5, le=300.0)
188
 
 
214
  self._recordings: dict[str, RecordingMetadata] = {}
215
  self._pending_recording: RecordingRequest | None = None
216
  self._pending_playback: str | None = None
 
217
  self._uploaded_audio: dict[str, Path] = {} # upload_id -> temp file path
218
  self._playback_cancel_event = threading.Event()
219
  self._recording_cancel_event = threading.Event()
 
329
  self._mode not in {"idle", "queued"}
330
  or self._pending_recording
331
  or self._pending_playback
 
332
  ):
333
  raise HTTPException(status_code=409, detail="Robot is busy.")
334
  self._preferred_duration = float(request.duration)
 
356
  self._mode != "idle"
357
  or self._pending_recording
358
  or self._pending_playback
 
359
  ):
360
  raise HTTPException(status_code=409, detail="Robot is busy.")
361
  self._pending_playback = payload.move_id
 
386
  self._refresh_recordings()
387
  return {"status": "deleted", "move_id": move_id}
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  @self.settings_app.get("/api/datasets")
390
  def list_datasets() -> dict[str, Any]:
391
  return self._datasets_payload()
 
397
  self._mode not in {"idle", "queued"}
398
  or self._pending_recording
399
  or self._pending_playback
 
400
  ):
401
  raise HTTPException(status_code=409, detail="Robot is busy.")
402
  entry = self._create_dataset(payload.name, payload.label)
 
411
  self._mode not in {"idle", "queued"}
412
  or self._pending_recording
413
  or self._pending_playback
 
414
  ):
415
  raise HTTPException(status_code=409, detail="Robot is busy.")
416
  self._select_dataset(payload.dataset_id)
 
424
  self._mode not in {"idle", "queued"}
425
  or self._pending_recording
426
  or self._pending_playback
 
427
  ):
428
  raise HTTPException(status_code=409, detail="Robot is busy.")
429
  result = self._sync_dataset(payload.hf_username, payload.move_ids, payload.dataset_slug)
 
436
  self._mode not in {"idle", "queued"}
437
  or self._pending_recording
438
  or self._pending_playback
 
439
  ):
440
  raise HTTPException(status_code=409, detail="Robot is busy.")
441
  self._set_dataset_root(payload.path)
 
569
  job_type = "play"
570
  payload = self._pending_playback
571
  self._pending_playback = None
 
 
 
 
572
 
573
  if job_type == "record" and isinstance(payload, RecordingRequest):
574
  self._perform_recording(reachy_mini, stop_event, payload)
 
577
  self._perform_playback(reachy_mini, payload)
578
  self._set_idle_state()
579
  self._align_head_and_release(reachy_mini)
 
 
 
580
  else:
581
  stop_event.wait(0.05)
582
  finally:
 
680
  self._set_state(mode="error", message=f"Move {move_id} missing.", active_move=None)
681
  return
682
 
 
683
  try:
684
+ move = self._load_move(meta.json_path)
685
  except Exception as exc: # pragma: no cover - filesystem failure
686
  self._set_state(
687
  mode="error",
 
728
  active_move=None,
729
  )
730
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  # ──────── Capture helpers ───────────────────────────────────────────
732
  def _capture_motion(
733
  self,
 
1638
  "recording_stats": self._recording_stats,
1639
  "pending_recording": self._pending_recording.label if self._pending_recording else None,
1640
  "pending_playback": self._pending_playback,
 
1641
  "moves": moves,
1642
  "config": {
1643
  "default_duration": DEFAULT_DURATION,
 
1649
  "dataset_root_path": str(self._dataset_root),
1650
  "features": dict(self._features),
1651
  "feature_support": {
 
1652
  "motion_models": True,
1653
  },
1654
  "motion_models": self._motion_model_registry.to_payload()
marionette/static/index.html CHANGED
@@ -211,18 +211,8 @@
211
  <details id="experimental-details">
212
  <summary>Experimental</summary>
213
  <div class="experimental-content">
214
- <p class="hint">Toggle prototype features. These may change or disappear without notice.</p>
215
- <div class="field-group">
216
- <label class="toggle">
217
- <input id="feature-denoise" type="checkbox" />
218
- <span>Enable motor-noise denoising for move audio</span>
219
- </label>
220
- <p class="hint small">
221
- Adds a per-move β€œDenoise” button that records the motor hum separately and subtracts it from the original
222
- track.
223
- </p>
224
- </div>
225
- <div class="field-group">
226
  <label class="toggle">
227
  <input id="feature-motion-models" type="checkbox" />
228
  <span>Enable motion compensation models</span>
 
211
  <details id="experimental-details">
212
  <summary>Experimental</summary>
213
  <div class="experimental-content">
214
+ <p class=”hint”>Toggle prototype features. These may change or disappear without notice.</p>
215
+ <div class=”field-group”>
 
 
 
 
 
 
 
 
 
 
216
  <label class="toggle">
217
  <input id="feature-motion-models" type="checkbox" />
218
  <span>Enable motion compensation models</span>
marionette/static/main.js CHANGED
@@ -59,7 +59,6 @@ const motionModelSelect = document.getElementById("motion-model-select");
59
  const motionModelHint = document.getElementById("motion-model-hint");
60
  const experimentalStatus = document.getElementById("experimental-status");
61
  const featureToggles = {
62
- denoise: document.getElementById("feature-denoise"),
63
  motionModels: document.getElementById("feature-motion-models"),
64
  };
65
 
@@ -258,10 +257,6 @@ function renderMoves(moves, mode, config) {
258
  updateSyncButtonState();
259
  return;
260
  }
261
- const features = (config && config.features) || {};
262
- const support = (config && config.feature_support) || {};
263
- const denoiseEnabled = !!features.denoise && support.denoise !== false;
264
-
265
  for (const move of moves) {
266
  const li = document.createElement("li");
267
  const selection = document.createElement("div");
@@ -292,16 +287,9 @@ function renderMoves(moves, mode, config) {
292
  const info = document.createElement("div");
293
  info.className = "move-info";
294
  const date = new Date(move.created_at * 1000).toLocaleString();
295
- const badges = [];
296
- if (move.has_denoised_audio) {
297
- badges.push("denoised");
298
- } else if (move.has_noise_profile) {
299
- badges.push("noise profile");
300
- }
301
- const badgeText = badges.length ? ` β€’ ${badges.join(", ")}` : "";
302
  info.textContent = `${formatDuration(move.duration)} β€’ ${
303
  move.has_audio ? "πŸŽ™ audio" : "🀐 silent"
304
- } β€’ ${date}${badgeText}`;
305
  meta.appendChild(info);
306
 
307
  const actions = document.createElement("div");
@@ -331,15 +319,6 @@ function renderMoves(moves, mode, config) {
331
  deleteBtn.addEventListener("click", () => deleteMove(move.id));
332
  actions.appendChild(deleteBtn);
333
 
334
- if (denoiseEnabled && move.has_audio) {
335
- const denoiseBtn = document.createElement("button");
336
- denoiseBtn.className = "play-btn";
337
- denoiseBtn.textContent = move.has_denoised_audio ? "Rebuild noise" : "Denoise";
338
- denoiseBtn.disabled = busy;
339
- denoiseBtn.addEventListener("click", () => queueDenoise(move.id));
340
- actions.appendChild(denoiseBtn);
341
- }
342
-
343
  li.appendChild(selection);
344
  li.appendChild(actions);
345
  movesList.appendChild(li);
@@ -425,22 +404,6 @@ async function queuePlayback(moveId) {
425
  }
426
  }
427
 
428
- async function queueDenoise(moveId) {
429
- if (!moveId) return;
430
- try {
431
- const resp = await fetch(`/api/moves/${encodeURIComponent(moveId)}/denoise`, {
432
- method: "POST",
433
- headers: { "Content-Type": "application/json" },
434
- });
435
- if (!resp.ok) throw new Error(await resp.text());
436
- primaryStatus.textContent = `Denoise queued for ${moveId}.`;
437
- await fetchState();
438
- } catch (error) {
439
- console.error("Denoise error:", error);
440
- primaryStatus.textContent = `Unable to denoise ${moveId}.`;
441
- }
442
- }
443
-
444
  recordForm.addEventListener("submit", queueRecording);
445
  refreshBtn.addEventListener("click", fetchState);
446
  if (datasetSelect) {
@@ -563,11 +526,6 @@ if (motionModelSelect) {
563
  updateMotionModel(value);
564
  });
565
  }
566
- if (featureToggles.denoise) {
567
- featureToggles.denoise.addEventListener("change", (event) => {
568
- updateExperimentalFeatures({ denoise: event.target.checked });
569
- });
570
- }
571
  if (featureToggles.motionModels) {
572
  featureToggles.motionModels.addEventListener("change", (event) => {
573
  updateExperimentalFeatures({ motion_models: event.target.checked });
@@ -750,8 +708,8 @@ function updatePhaseFromState(state) {
750
  phaseStartAt = null;
751
  phaseEndAt = null;
752
  }
753
- // For "playing" or "denoise", keep the phase cleared
754
- if (state.mode === "playing" || state.mode === "denoise") {
755
  phaseMode = state.mode;
756
  phaseStartAt = null;
757
  phaseEndAt = null;
@@ -1109,14 +1067,6 @@ function updateExperimentalUI(config) {
1109
  experimentalPanel.hidden = false;
1110
  const features = config.features || {};
1111
  const support = config.feature_support || {};
1112
- if (featureToggles.denoise) {
1113
- featureToggles.denoise.checked = !!features.denoise;
1114
- const supported = support.denoise !== false;
1115
- featureToggles.denoise.disabled = !supported;
1116
- featureToggles.denoise.title = supported
1117
- ? "Capture and subtract motor noise."
1118
- : "Audio backend unavailable, cannot capture motor noise.";
1119
- }
1120
  if (featureToggles.motionModels) {
1121
  featureToggles.motionModels.checked = !!features.motion_models;
1122
  }
 
59
  const motionModelHint = document.getElementById("motion-model-hint");
60
  const experimentalStatus = document.getElementById("experimental-status");
61
  const featureToggles = {
 
62
  motionModels: document.getElementById("feature-motion-models"),
63
  };
64
 
 
257
  updateSyncButtonState();
258
  return;
259
  }
 
 
 
 
260
  for (const move of moves) {
261
  const li = document.createElement("li");
262
  const selection = document.createElement("div");
 
287
  const info = document.createElement("div");
288
  info.className = "move-info";
289
  const date = new Date(move.created_at * 1000).toLocaleString();
 
 
 
 
 
 
 
290
  info.textContent = `${formatDuration(move.duration)} β€’ ${
291
  move.has_audio ? "πŸŽ™ audio" : "🀐 silent"
292
+ } β€’ ${date}`;
293
  meta.appendChild(info);
294
 
295
  const actions = document.createElement("div");
 
319
  deleteBtn.addEventListener("click", () => deleteMove(move.id));
320
  actions.appendChild(deleteBtn);
321
 
 
 
 
 
 
 
 
 
 
322
  li.appendChild(selection);
323
  li.appendChild(actions);
324
  movesList.appendChild(li);
 
404
  }
405
  }
406
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  recordForm.addEventListener("submit", queueRecording);
408
  refreshBtn.addEventListener("click", fetchState);
409
  if (datasetSelect) {
 
526
  updateMotionModel(value);
527
  });
528
  }
 
 
 
 
 
529
  if (featureToggles.motionModels) {
530
  featureToggles.motionModels.addEventListener("change", (event) => {
531
  updateExperimentalFeatures({ motion_models: event.target.checked });
 
708
  phaseStartAt = null;
709
  phaseEndAt = null;
710
  }
711
+ // For "playing", keep the phase cleared
712
+ if (state.mode === "playing") {
713
  phaseMode = state.mode;
714
  phaseStartAt = null;
715
  phaseEndAt = null;
 
1067
  experimentalPanel.hidden = false;
1068
  const features = config.features || {};
1069
  const support = config.feature_support || {};
 
 
 
 
 
 
 
 
1070
  if (featureToggles.motionModels) {
1071
  featureToggles.motionModels.checked = !!features.motion_models;
1072
  }
tests/test_api.py CHANGED
@@ -72,7 +72,7 @@ class TestStateEndpoint:
72
  "phase_start_at", "phase_end_at",
73
  "countdown_ends_at",
74
  "recording_started_at", "recording_duration", "recording_stats",
75
- "pending_recording", "pending_playback", "pending_denoise",
76
  "moves", "config", "datasets",
77
  }
78
  assert required_keys.issubset(data.keys())
@@ -105,7 +105,6 @@ class TestStateEndpoint:
105
  data = client.get("/api/state").json()
106
  assert data["pending_recording"] is None
107
  assert data["pending_playback"] is None
108
- assert data["pending_denoise"] is None
109
 
110
 
111
  # ──────── Recording endpoint tests ────────────────────────────────────
@@ -422,10 +421,10 @@ class TestMovesRefresh:
422
 
423
 
424
  class TestExperiments:
425
- def test_toggle_denoise(self, client: TestClient):
426
- resp = client.post("/api/experiments", json={"denoise": True})
427
- assert resp.status_code == 200
428
- assert resp.json()["features"]["denoise"] is True
429
 
430
  def test_toggle_motion_models(self, client: TestClient):
431
  resp = client.post("/api/experiments", json={"motion_models": True})
 
72
  "phase_start_at", "phase_end_at",
73
  "countdown_ends_at",
74
  "recording_started_at", "recording_duration", "recording_stats",
75
+ "pending_recording", "pending_playback",
76
  "moves", "config", "datasets",
77
  }
78
  assert required_keys.issubset(data.keys())
 
105
  data = client.get("/api/state").json()
106
  assert data["pending_recording"] is None
107
  assert data["pending_playback"] is None
 
108
 
109
 
110
  # ──────── Recording endpoint tests ────────────────────────────────────
 
421
 
422
 
423
  class TestExperiments:
424
+ def test_denoise_feature_removed(self, client: TestClient):
425
+ """Denoise feature was removed; the features dict should not contain it."""
426
+ data = client.get("/api/state").json()
427
+ assert "denoise" not in data["config"]["features"]
428
 
429
  def test_toggle_motion_models(self, client: TestClient):
430
  resp = client.post("/api/experiments", json={"motion_models": True})