Efren Yevale Varela 1 месяц назад
Родитель
Сommit
07e923f592
2 измененных файлов с 90 добавлено и 20 удалено
  1. 12 2
      README.md
  2. 78 18
      src/stt_daemon/provisioner.py

+ 12 - 2
README.md

@@ -324,7 +324,13 @@ uv run stt-daemon clear
 uv run stt-daemon clear --yes
 uv run stt-daemon clear --yes
 ```
 ```
 
 
-This unloads all tracked modules and removes the state file.
+This unloads all tracked modules and removes the state file. If
+`~/.local/state/stt-daemon/audio.json` is already gone (reboot, manual
+delete, crash), `clear --yes` still finds orphaned modules by name
+(`VirtualMicSink`/`VirtualMic` via `pactl list short modules`) and unloads
+them in reverse order (loopbacks → remap-source → null-sink), so the
+command is idempotent — safe to re-run and to recover from
+`Sink 'VirtualMicSink' already exists but is not tracked`.
 
 
 #### (Legacy) `--list-sources` flag
 #### (Legacy) `--list-sources` flag
 
 
@@ -501,7 +507,11 @@ When you run `uv run stt-daemon setup`, the daemon:
 - Creates all modules and records their numeric IDs
 - Creates all modules and records their numeric IDs
 - Saves the state to `~/.local/state/stt-daemon/audio.json` (or `$STT_DAEMON_STATE`)
 - Saves the state to `~/.local/state/stt-daemon/audio.json` (or `$STT_DAEMON_STATE`)
 - Allows `run` to re-provision if the sink is missing (e.g., after reboot)
 - Allows `run` to re-provision if the sink is missing (e.g., after reboot)
-- Allows `clear` to unload only the modules it created
+- Allows `clear` to unload the modules it created — and falls back to
+  name-based discovery (`sink_name`/`remap_source_name` in
+  `pactl list short modules`) when `audio.json` is missing, so orphaned
+  `VirtualMicSink`/`VirtualMic` sinks are still cleaned up (e.g., after a
+  reboot or manual state-file delete)
 
 
 The state file is JSON and can be edited manually if needed, but the daemon handles it automatically.
 The state file is JSON and can be edited manually if needed, but the daemon handles it automatically.
 
 

+ 78 - 18
src/stt_daemon/provisioner.py

@@ -112,33 +112,93 @@ class Provisioner:
 
 
         Unloads in reverse creation order (loopbacks, then remap, then null-sink).
         Unloads in reverse creation order (loopbacks, then remap, then null-sink).
         Ignores modules that are already gone. Always clears the state file.
         Ignores modules that are already gone. Always clears the state file.
+        Falls back to name-based discovery when no state file exists so
+        ``clear --yes`` can clean orphans (e.g. after reboot or manual delete).
         """
         """
         state = self.store.load()
         state = self.store.load()
-        if not state:
-            self.store.clear()
-            return
+        if state:
+            # Unload loopbacks in reverse order
+            for loopback in reversed(state.loopbacks):
+                try:
+                    self.pa.unload_module(loopback.module_id)
+                except RuntimeError:
+                    pass  # Already gone is fine
+
+            # Unload remap source
+            try:
+                self.pa.unload_module(state.remap_module)
+            except RuntimeError:
+                pass
 
 
-        # Unload loopbacks in reverse order
-        for loopback in reversed(state.loopbacks):
+            # Unload null sink
             try:
             try:
-                self.pa.unload_module(loopback.module_id)
+                self.pa.unload_module(state.null_sink_module)
             except RuntimeError:
             except RuntimeError:
-                pass  # Already gone is fine
+                pass
 
 
-        # Unload remap source
-        try:
-            self.pa.unload_module(state.remap_module)
-        except RuntimeError:
-            pass
+            self.store.clear()
+            # Sweep any remaining orphans whose IDs changed since state was saved
+            self._sweep_orphans()
+            return
+
+        # No state file — discover owned modules by name and unload them
+        self._sweep_orphans()
+        self.store.clear()
+
+    def _sweep_orphans(self) -> None:
+        """Unload any live modules that match our sink/remap names.
 
 
-        # Unload null sink
+        Scans ``pactl list short modules`` and matches on the argument string
+        so cleanup works without ``audio.json`` (orphan case).
+        """
         try:
         try:
-            self.pa.unload_module(state.null_sink_module)
-        except RuntimeError:
-            pass
+            output = self.pa.runner(["list", "short", "modules"])
+        except Exception:
+            return
 
 
-        # Always clear state
-        self.store.clear()
+        loopbacks: list[int] = []
+        remap_id: int | None = None
+        null_sink_id: int | None = None
+        sink_name = self.cfg.sink_name
+        remap_name = self.cfg.remap_source_name
+        monitor_source = f"{sink_name}.monitor"
+
+        for line in output.strip().split("\n"):
+            if not line.strip():
+                continue
+            parts = line.split("\t")
+            if len(parts) < 2:
+                continue
+            try:
+                mid = int(parts[0])
+            except ValueError:
+                continue
+            name = parts[1]
+            args_str = "\t".join(parts[2:]) if len(parts) > 2 else ""
+            # also handle space-joined args in some pactl versions
+            if name == "module-null-sink" and f"sink_name={sink_name}" in args_str:
+                null_sink_id = mid
+            elif name == "module-remap-source" and f"source_name={remap_name}" in args_str:
+                remap_id = mid
+            elif name == "module-loopback":
+                if f"sink={sink_name}" in args_str or f"source={monitor_source}" in args_str:
+                    loopbacks.append(mid)
+
+        for mid in reversed(loopbacks):
+            try:
+                self.pa.unload_module(mid)
+            except RuntimeError:
+                pass
+        if remap_id is not None:
+            try:
+                self.pa.unload_module(remap_id)
+            except RuntimeError:
+                pass
+        if null_sink_id is not None:
+            try:
+                self.pa.unload_module(null_sink_id)
+            except RuntimeError:
+                pass
 
 
     def ensure(self) -> AudioState | None:
     def ensure(self) -> AudioState | None:
         """Ensure provisioning exists; re-provision if sink is missing.
         """Ensure provisioning exists; re-provision if sink is missing.