state.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. """Persistence of virtual audio sink state."""
  2. import json
  3. import os
  4. import tempfile
  5. from dataclasses import asdict, dataclass, field
  6. from pathlib import Path
  7. @dataclass
  8. class Loopback:
  9. """A loopback module that connects a source to the sink."""
  10. module_id: int
  11. source: str
  12. @dataclass
  13. class AudioState:
  14. """Complete state of the provisioned virtual audio sink."""
  15. sink_name: str
  16. remap_name: str
  17. null_sink_module: int
  18. remap_module: int
  19. loopbacks: list[Loopback] = field(default_factory=list)
  20. selected_sources: list[str] = field(default_factory=list)
  21. class StateStore:
  22. """Persist and restore AudioState from filesystem."""
  23. def __init__(self, path: Path | None = None):
  24. """Initialize the state store.
  25. Args:
  26. path: Path to the state file. If None, uses `$STT_DAEMON_STATE` or
  27. `audio.json` relative to the current directory.
  28. """
  29. if path is None:
  30. env_path = os.environ.get("STT_DAEMON_STATE")
  31. path = Path(env_path) if env_path else Path("audio.json")
  32. self.path = path
  33. def load(self) -> AudioState | None:
  34. """Load state from file.
  35. Returns:
  36. AudioState if file exists and is valid JSON, None otherwise.
  37. Never raises; returns None on missing or corrupt files.
  38. """
  39. try:
  40. if not self.path.exists():
  41. return None
  42. with open(self.path, "r", encoding="utf-8") as f:
  43. data = json.load(f)
  44. # Reconstruct Loopback objects
  45. loopbacks = [
  46. Loopback(
  47. module_id=lb["module_id"],
  48. source=lb["source"],
  49. )
  50. for lb in data.get("loopbacks", [])
  51. ]
  52. return AudioState(
  53. sink_name=data["sink_name"],
  54. remap_name=data["remap_name"],
  55. null_sink_module=data["null_sink_module"],
  56. remap_module=data["remap_module"],
  57. loopbacks=loopbacks,
  58. selected_sources=data.get("selected_sources", []),
  59. )
  60. except (json.JSONDecodeError, KeyError, TypeError, OSError, ValueError):
  61. return None
  62. def save(self, state: AudioState) -> None:
  63. """Save state to file.
  64. Creates parent directories if needed. Writes via a temp file and
  65. atomic rename so a crash mid-write cannot corrupt the state file.
  66. Args:
  67. state: AudioState to persist.
  68. """
  69. self.path.parent.mkdir(parents=True, exist_ok=True)
  70. data = asdict(state)
  71. fd, tmp_path = tempfile.mkstemp(
  72. dir=self.path.parent, prefix=".audio-", suffix=".tmp"
  73. )
  74. try:
  75. with os.fdopen(fd, "w", encoding="utf-8") as f:
  76. json.dump(data, f, indent=2)
  77. os.replace(tmp_path, self.path)
  78. except BaseException:
  79. try:
  80. os.unlink(tmp_path)
  81. except OSError:
  82. pass
  83. raise
  84. def clear(self) -> None:
  85. """Remove the state file if it exists."""
  86. if self.path.exists():
  87. self.path.unlink()