anomalyco / anomalyco/opencode

[Bug] Desktop 1.18.11 (Linux): sound effects never play, while system notifications work

Open
#40,440 0 comments 0 reactions 1 assignee View on GitHub

@Hona is already working on this.

Since Aug 4, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Description

Summary

On Linux, nothing in Settings → General → Sound effects produces audible output — not the Agent / Permissions / Errors events, and not the preview when picking a sound from the dropdown. System notifications from the section directly above work correctly, so the underlying events do reach the UI.

I could not find the cause, but I ruled out the obvious candidates below. A plugin-based workaround for other Linux users is at the end.

Environment

Version 1.18.11 (opencode-1.18.11-1.x86_64)
Artifact opencode-desktop-linux-x86_64.rpm from https://opencode.ai/download/stable/linux-x64-rpm
Integrity sha512 matches latest-linux.yml for v1.18.11
OS Fedora Linux 43, KDE Plasma, Wayland
Audio PipeWire 1.4.11 with PulseAudio compatibility, libpulse.so.0 present
Launch flag --disable-gpu (see "Unrelated note")

Steps to reproduce

  1. Install the Linux RPM and launch the app.
  2. Settings → General → Sound effects — each of Agent / Permissions / Errors has a sound selected (defaults).
  3. Open the Agent dropdown and pick a different sound. No preview is audible.
  4. Trigger the real events: run a command that needs permission approval, and let a session finish. No sound.

Reproduced across several restarts.

What I ruled out

The audio assets are present. app.asar contains 26 bundled .aac files (alert-01-BuOD_o_q.aac, bip-bop-03-DXp7Zb0f.aac, …) plus 19 base64 data:audio/aac URIs inlined in JS chunks. The three defaults ship as chunks with inlined data URIs — staplebops-01-CuX2RZ1A.js, staplebops-02-DGL-LyGN.js, nope-03-OvZJOdvj.js. The base64 payloads start //FQ (0xFF 0xF1), i.e. raw ADTS AAC.

The AAC decoder is compiled into the bundled ffmpeg. /opt/OpenCode/libffmpeg.so exports ff_aac_decoder (alongside ff_mp3_decoder, ff_vorbis_decoder, ff_flac_decoder, ff_h264_decoder). This is not a stripped "clean" ffmpeg.

The playback path exists and is wired to the dropdown. The bundle contains playSound / playSoundById, new Audio(src), five .play() call sites and two canPlayType uses. The settings dropdown calls playSoundById(id).then((cleanup) => …), so the preview is intended to be audible.

Host audio is healthy. Firefox and Spotify play to the same sink throughout. Default sink volume ~91%, no per-application mute stored in WirePlumber for this app. The Chromium audio.mojom.AudioService utility process runs and the app registers as a PipeWire client:

$ pactl list clients short | grep opencode
8017    PipeWire        ai.opencode.desktop

No renderer error is logged. The app's renderer console output does reach the systemd journal (unrelated ResizeObserver warnings from the same window show up there). While changing the sound in the dropdown, the journal recorded only those ResizeObserver lines — no NotSupportedError, no EncodingError, no autoplay-policy message, nothing audio-related.

So: assets present, decoder present, playback code present, no error raised, no sound.

One thing I could not determine: whether an HTMLAudioElement is actually created and play() reached at runtime. I have no way to inspect the renderer beyond the journal on this build. If there is a debug flag or a DevTools entry point I missed, I am happy to collect more.

I also monitored pactl subscribe for 150 s while triggering events. One new sink-input appeared, but I could not attribute it: an Electron app and a running Chrome instance both report application.name = "Chromium". So I cannot claim the app opened no playback stream.

Expected

Selecting a sound effect and triggering the corresponding event plays the sound, as it appears to on Windows (#40038, #32389).

Workaround for Linux users

A local plugin reproduces the feature using the freedesktop system sounds most Linux desktops already ship. Save as ~/.config/opencode/plugins/system-sounds.js and restart. This is the code running on the machine described above, with its comments translated from Finnish; the logic is unchanged.

import { appendFileSync } from "node:fs"
import { execFile } from "node:child_process"

const SOUND_DIR = "/usr/share/sounds/freedesktop/stereo"
const ERROR_LOG = "/tmp/opencode-sounds.log"

// Only announce completion if the turn took longer than this. A fast reply
// means you were probably watching, so the sound is just noise. Focus
// detection would be more precise but is awkward on Wayland.
const IDLE_MIN_MS = 30_000

const SOUNDS = {
  permission: "message-new-instant.oga", // permission needed - always
  idle: "complete.oga",                  // agent done - only after a long turn
  error: "dialog-error.oga",             // error - always
}

let turnStart = null

// Errors only. Hundreds of events per minute arrive (message.part.delta per
// token), so writing per event would be a synchronous disk write per token.
const logError = (msg) => {
  try {
    appendFileSync(ERROR_LOG, `${new Date().toISOString()} ${msg}\n`)
  } catch {}
}

const play = (file) => {
  execFile("paplay", [`${SOUND_DIR}/${file}`], (err) => {
    if (err) logError(`paplay ${file}: ${err.message}`)
  })
}

export const SystemSounds = async () => ({
  event: async ({ event }) => {
    const type = event.type

    if (type === "session.idle") {
      const elapsed = turnStart === null ? 0 : Date.now() - turnStart
      turnStart = null
      if (elapsed >= IDLE_MIN_MS) play(SOUNDS.idle)
      return
    }

    // Start the clock only on message events. Background events (file watcher,
    // LSP, installs) would otherwise start it long before a turn begins.
    if (turnStart === null && type.startsWith("message.")) turnStart = Date.now()

    if (type === "permission.asked") play(SOUNDS.permission)
    else if (type === "session.error") play(SOUNDS.error)
  },
})

Two caveats if you use it: turnStart is global rather than per-session, so a session.idle from another session can reset it; and time spent waiting at a permission prompt counts toward IDLE_MIN_MS.

Unrelated note

I run the app with --disable-gpu, because without it the GPU process fails to launch and the app aborts:

ERROR:gpu_process_host.cc:992] GPU process launch failed: error_code=1002   (x6)
FATAL:gpu_data_manager_impl_private.cc:418] GPU process isn't usable. Goodbye.

That is a separate problem (NVIDIA + KDE Wayland) which I have not filed. Noting it only so the flag is not mistaken for a cause — the sound behaviour was identical before I added it.

Related

  • #40038 — desktop plays the success notification sound immediately on sending a message
  • #32389 — [Desktop] repeated error alert sounds on startup

Both are Windows reports, which is what pointed me at a platform difference.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.