Skip to content

feat(settings): system volume control with two-way OS sync#69

Draft
lukyrys wants to merge 23 commits into
mainfrom
feat/107-system-volume-backend
Draft

feat(settings): system volume control with two-way OS sync#69
lukyrys wants to merge 23 commits into
mainfrom
feat/107-system-volume-backend

Conversation

@lukyrys

@lukyrys lukyrys commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Adds system audio volume control that keeps the app and the OS volume in sync both ways.

  • the footer volume +/- controls now set the OS master volume (Windows CoreAudio, macOS osascript, Linux amixer with a pactl fallback)
  • OS-side changes (system tray, media keys, other apps) push to the widget near-instantly via a lightweight monitor process, with a 5s poll as fallback
  • the widget shows an "unavailable" state (no percentage) when the system has no controllable audio device, instead of a fake value
  • writes are serialized latest-wins and the widget shows a neutral state until the first live reading
  • new translation key settings.footerWidgets.volumeUnavailable (EN "Audio device not available", CS "Zvukové zařízení není k dispozici")

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bac4a6ac19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/src/api/system.ts
* persisted level instead of falsely reporting "unavailable".
*/
async function getVolume(): Promise<{ volume: number | null; available: boolean }> {
const status = await getSystemVolumeStatus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid racing the initial volume write

When a client connects while the fire-and-forget startup setSystemVolume(startupVolume) is still running, this live read can return the pre-write OS level. The startup .then later seeds volumeWatcher with the written value, so the next poll sees no diff and never corrects that client; the footer can then stay on the old level until the user changes volume again. Gate getVolume on the pending/serialized write or cache the startup result before answering the initial read.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 2916034 — the startup mixer write was removed entirely. The backend now only reads the OS volume on start and adopts it (seed + persisted preference), so there is no pre-write level a concurrent client read could observe.

Comment thread backend/src/api/system.ts
const volumeWatcher = createVolumeWatcher({
getStatus: getSystemVolumeStatus,
broadcast: status => broadcast('system:volumeChanged', status),
persist: v => void settings.set('audio.volume', v),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize volume-change persistence

When the OS monitor emits several volume changes quickly, this fire-and-forget settings.set starts overlapping whole-file writes through JSONStorage.set; an older write can complete after a newer one and leave audio.volume stale on disk. Await or queue these persists so media-key/tray bursts cannot reorder the saved preference.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f864a98 — whole-file JSON writes are now chained per storage instance, so overlapping set() calls can no longer interleave truncate+write or finish out of order.

Comment thread backend/src/api/system.ts
const res = await setSystemVolume(pct);
// Seed the watcher with the value the mixer ACTUALLY ended on (res.volume,
// which under latest-wins may differ from pct) so its poll does not echo it.
volumeWatcher.remember({ volume: res.volume, available: res.available });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Broadcast app-initiated volume writes

When one connected client uses the +/- controls, system.setVolume updates only that client's local store and then remembers the new mixer state, so the watcher suppresses the subsequent poll/monitor echo instead of notifying other subscribed clients. Any other open window/tab keeps showing the old volume until a separate OS-side change happens; broadcast the resulting {volume, available} from this handler as well as remembering it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a6f9296 — system.setVolume now broadcasts the resulting {volume, available} to subscribers. The originating client ignores the level while its own adjustment is fresh, so it cannot fight in-progress input.

@lukyrys

lukyrys commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6f92963ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/src/system-volume.ts Outdated
Comment on lines +85 to +86
const amixer = await tryRun('amixer', ['get', 'Master']);
const pactl = amixer !== null && parseAlsaVolume(amixer) !== null ? null : await tryRun('pactl', ['get-sink-volume', '@DEFAULT_SINK@']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer the Pulse sink before the ALSA mixer

On Linux systems that have both PulseAudio/PipeWire (pactl) and an ALSA Master control, these lines stop as soon as the ALSA read succeeds, while the monitor below subscribes to Pulse sink events. amixer(1) controls the ALSA soundcard mixer, whereas pactl(1) @DEFAULT_SINK@ commands target the default Pulse sink; in that setup tray/media-key changes update the Pulse sink but getVolume keeps returning the unrelated ALSA value, and the mirrored write path sends +/- changes to the wrong mixer unless amixer fails. Try pactl first and fall back to amixer only when no default sink is available.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — both the read and write paths now try the Pulse/PipeWire default sink first and fall back to ALSA Master only when no default sink answers, matching the pactl-based monitor.

@lukyrys

lukyrys commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 202a54e416

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread backend/src/api/system.ts
// present; (re)spawn on crash or when a device reappears, stop otherwise.
if (volumeWanted && volumeWatcher.available() && !volumeMonitor) {
volumeMonitor = startVolumeMonitor(
status => volumeWatcher.ingest(status),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard push volume events during local writes

When a client changes the volume while the instant monitor is running, this callback sends monitor readings straight into volumeWatcher.ingest(), bypassing the isMixerWriteBusy() guard that only poll() uses. On Windows the 150 ms monitor, and on Linux pactl subscribe, can observe the mixer while our own write is still in flight or settling, so an old/intermediate value can be persisted and broadcast as an external change despite the serializer's settle window.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5866fde — the busy guard moved into the watcher's ingest(), so monitor pushes observed while a write is in flight or settling are dropped for both the Windows timer monitor and the pactl subscriber.

Comment thread backend/src/api/system.ts Outdated
// (a second window/tab) would never hear about it — tell them directly.
// The originating client ignores the level while its own adjustment is
// fresh, so this cannot fight the user's in-progress input.
if (res.success) broadcast('system:volumeChanged', { volume: res.volume, available: res.available });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Broadcast unavailable writes to other clients

When a device disappears during a volume change, setSystemVolume() returns success: false, available: false; this code still seeds the watcher with that unavailable state but then skips the broadcast. Because the next poll sees the same remembered {available:false} value, it will suppress the event too, so other subscribed windows can keep showing the old volume/device-available state indefinitely.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ffb5ba4 — the write result is now broadcast unconditionally, so a device vanishing mid-write delivers available:false to all subscribers instead of being suppressed forever.

Comment thread frontend/src/scripts/settings.ts Outdated
// Availability (device plug/unplug) always applies; the level is skipped
// while the user is mid-adjustment so a stale poll cannot fight their input.
volumeAvailable.set(data.available);
if (Date.now() - lastLocalVolumeChange > 1000 && data.available && data.volume !== null) volume.set(data.volume);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replay volume events dropped during local edits

When another window or the OS changes the volume within one second of this client pressing +/- , this condition drops that system:volumeChanged level permanently rather than deferring it. The backend remembers its own write and suppresses later identical polls, and the system.setVolume response does not include the final volume, so this client can remain stuck showing its local in-progress value until some future volume change happens.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ab6fa13 — events inside the guard window are deferred (latest wins) and replayed once the window expires; a newer local edit discards the deferred level since the local write supersedes it.

Comment on lines +88 to +90
const pactl = await tryRun('pactl', ['get-sink-volume', '@DEFAULT_SINK@']);
const amixer = pactl !== null && parseAlsaVolume(pactl) !== null ? null : await tryRun('amixer', ['get', 'Master']);
return classifyMixerReadings([pactl, amixer]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve availability on transient mixer failures

On Linux, tryRun() returns null for any pactl/amixer failure, including a timeout or other transient command error, and this all-null path is passed into classifyMixerReadings() as no-device. That contradicts the getSystemVolumeStatus() contract that transient helper failures should be indeterminate, and it can incorrectly broadcast/persist available:false for users whose audio device is still present.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e8d0ae9 — tryRun rethrows timeout kills so they classify as transient error (indeterminate) instead of no-device; a missing binary or non-zero exit (e.g. pactl 'No such entity') stays a definitive no-device signal.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Repository owner deleted a comment from chatgpt-codex-connector Bot Jul 18, 2026
Repository owner deleted a comment from chatgpt-codex-connector Bot Jul 18, 2026
@lukyrys
lukyrys marked this pull request as draft July 18, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant