Skip to content

TUI keeps audio output active after notification playback #54432

Open

Description

@VitorNathanG

Description

The TUI starts audio output for notification sounds but does not stop it when playback finishes.

On macOS, this prevents Bluetooth headphones from switching playback to another device. A local patch that stops output when no voices remain active fixes the problem. Other platforms have not been tested.

Steps to reproduce

  • Trigger a TUI notification sound.
  • Wait for playback to finish.
  • Observe that audio output remains active.

Environment

OpenCode 2.0.26, macOS 27.0.1, Apple Silicon.

Activity

  1. opencode-agent commented on Oct 11, 2026

    @opencode-agent
    Contributor

    Thanks for the report! I was able to reproduce this. I don't have a macOS machine here, so I tested on Linux with PulseAudio, and it behaves the same way there.

    Steps: turn on attention sounds (attention: { sound: true }), send a prompt and let the session finish so the "Session done" sound plays, then list the audio streams with pactl list short sink-inputs.

    What happens: before the prompt there are no streams. After the sound plays, one stream from opencode is still open 15 seconds later, well after the ~1 s sound has ended. It stays open until opencode exits.

    It still happens on the latest dev (0d61729b3). In packages/tui/src/audio.ts, play() starts the audio engine the first time a sound plays, but nothing stops it again. It is only released when the TUI shuts down. That fits your finding that stopping output once no voices are active fixes it.

    The script below reproduces it. The open stream doesn't appear on screen, so the evidence is the pactl output the script logs; the recording only shows the session finishing.

    Related: #35242 reported the same kind of leak in v1 (a notification sound kept a macOS output stream open and coreaudiod busy). It was closed for inactivity without a fix.

    recording.mp4
    Reproduction script (drive.ts)
    // Repro for #54432: after a TUI attention sound finishes, the audio output stream stays open.
    // Needs a PulseAudio server and `pactl`, e.g. on a headless Linux box:
    //   apt-get install -y pulseaudio pulseaudio-utils && pulseaudio -D --exit-idle-time=-1
    //   export PULSE_SERVER=unix:/tmp/pulse-<id>/native   # so the TUI and pactl reach the same server
    // Then: opencode-drive run drive.ts   (OC_DEV=/path/to/checkout to run from source)
    import { Effect } from "effect"
    import { execSync } from "node:child_process"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const sinkInputs = () => {
      const out = execSync("pactl list short sink-inputs", { encoding: "utf8" }).trim()
      return out ? out.split("\n") : []
    }
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        tuiConfig: { attention: { sound: true, volume: 1 } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* Effect.log(`sink-inputs before: ${sinkInputs().length}`)
          yield* llm.queue(Llm.text("Done."))
          yield* ui.submit("say done")
          yield* ui.waitFor("Done.")
          // The "Session done" sound is ~1s long; wait well past the end of playback.
          for (const s of [2, 5, 10, 15]) {
            yield* Effect.sleep(s === 2 ? "2 seconds" : s === 5 ? "3 seconds" : "5 seconds")
            const inputs = sinkInputs()
            yield* Effect.log(`sink-inputs ${s}s after session done: ${inputs.length} ${JSON.stringify(inputs)}`)
          }
          yield* ui.screenshot("after-sound")
          const remaining = sinkInputs().length
          if (remaining > 0) yield* Effect.log(`BUG REPRODUCED: ${remaining} audio output stream(s) still open after playback finished`)
          else yield* Effect.log("OK: no audio output stream left open")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. infinitr0us commented on Oct 20, 2026

    @infinitr0us

    Same here on v2.0.32 (macOS 27.0.1, MacBook Pro M4, Ghostty 1.3.1). Besides the Bluetooth switching, the open stream also costs CPU and blocks idle sleep on macOS, so here are some numbers, plus a test of #54434.

    Measured with attention.sound: true, an idle TUI and a local fake model (CPU as % of one core, over 20–30 s):

    • No TUI, or a freshly started TUI before any sound has played: coreaudiod 0.0–0.1%, no assertions.
    • One TUI after a single "Session done" sound: coreaudiod 8.5–8.8%, steady for as long as the TUI sits idle. pmset -g assertions shows two PreventUserIdleSystemSleep assertions held by coreaudiod (an AudioTap and BuiltInSpeakerDevice).
    • Three TUIs, one sound each: coreaudiod 11.6%, three assertions.
    • After quitting the TUI: back to ~0% within seconds, assertions gone.
    • Same test with attention.sound: false: coreaudiod stays at 0.0%.
    • The TUI process itself stays at ~1% in every case, so the cost is all in coreaudiod.

    This matches what I saw in daily use: with a few TUIs left open after agent runs, the Mac carried extra load until I quit them. Since a fresh TUI stays at 0% until its first sound, #56188 (closed as a duplicate of this one) fits the same cause.

    Tested #54434: ran the PR head (5b13af4) and its base (3c61484) from source with the same script, two turns each, each turn playing the "Session done" sound:

    • Base: the stream opens when the sound plays and never closes; coreaudiod 8.3% while idle, two assertions held after both turns.
    • PR: the stream opens when the sound plays and is released within a few seconds (assertions gone by ~5 s); coreaudiod 0.0% while idle after each turn. The second sound restarts the engine as expected.
    • bun test test/audio-idle.test.ts in packages/tui: 2 pass.

    So #54434 fixes the CPU and idle sleep side as well. Until it lands, attention.sound: false plus a TUI restart works around it.

    Not tried: Bluetooth output (only the built-in speakers).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions