Skip to content

Spoken voice never follows a mid-session output-device switch — no output-side equivalent of the mic's device-change rebuild #29

Description

@techmik

The mic side already recovers from a runtime device change: ears._reopen_after_device_change rebuilds PortAudio and reopens the input stream.
The speaking side has no equivalent.
If the OS default output device changes while a session is running — earbuds die, you switch to desk speakers, a headset drops off — the voice keeps playing into the old device until you restart backtalk.
The thinking sound and beeps do follow the switch, because signals.py spawns a fresh player subprocess per play, so the split is confusing in practice: everything moves except the one thing you care about.
There's also no output_device config key (there's mic_device, no speaker equivalent), so even a restart only helps if the device you want is now the system default.
Why it happens: audio law #1 — Mouth holds ONE sd.OutputStream for the life of the process (_get_out, mouth.py:419) and deliberately never reopens it under normal play, because a fresh stream per sentence blips on USB/Bluetooth.
PortAudio snapshots the device list at sd._initialize().
So a stream opened on device A at launch keeps writing to device A regardless of what the OS default does afterwards.
And unlike the mic — where a device change surfaces as a failed capture that triggers the rebuild — a stream writing to a stale-but-still-present device raises no error, so there's nothing for an automatic recovery to hook onto.
Observed on: 84b3a6c, Windows 11 Home 10.0.26200, Python 3.12.10.
Not platform-specific — the same thing happens on macOS switching to AirPods.

Reproduction Start a session with earbuds as the default output.

Mid-session, change the Windows/macOS default output to speakers.
Ask a question: the thinking sound plays on the speakers, the reply plays on the earbuds.
Stays that way until restart.

Fix A voice-console verb that rebuilds PortAudio on request — the same sd._terminate() / sd._initialize() dance ears._reopen_after_device_change already does — after which Mouth._get_out's existing dead-stream guard reopens the output stream on the new default on the next sentence, at the cost of one onset blip.

Manual rather than automatic because there's no error to trigger on; polling the default device every turn felt heavier than it's worth, but that's a design call — you might prefer detection.
mouth.py — new method next to _drop_out:

def rebuild_audio(self) -> bool:
    """Tear down and re-initialise PortAudio so the NEXT sentence opens
    its output stream on whatever the OS calls the default device now.
    The on-request twin of ears._reopen_after_device_change. Leaves the
    held stream stale on purpose: _get_out()'s dead-stream guard rebuilds
    it fresh next sentence, at the cost of one onset blip. Call with the
    mouth already quiesced (shut_up() then wait_done())."""
    try:
        sd._terminate()
    except Exception:
        pass
    try:
        sd._initialize()
    except Exception as e:
        log(f"[mouth] could not re-initialise the audio system: {e}")
        return False
    log("[mouth] audio system rebuilt -- next sentence opens on the "
        "current default output device")
    return True

main.py — a CONSOLE_VERBS entry (next to the other verbs at main.py:301):

"swapout":   ("switch audio output", "switch the audio output",
              "swap audio output", "swap the audio output",
              "change audio output", "change the audio output",
              "switch output device", "switch the output device",
              "switch audio device", "switch the audio device"),

and a handler branch in _run_console_inner (next to verb == "ask"):

elif verb == "swapout":
    resp = ""
    mouth.shut_up()
    mouth.wait_done(timeout=2)
    if mouth.rebuild_audio():
        mouth.say("Audio output switched. If you can hear this, "
                  "it's on the current default device.")
    else:
        mouth.say("I couldn't rebuild the audio system. Check "
                  "this window for the error.")

Checked here: with the verb, a live earbuds→speaker switch moves the voice on the next sentence; console_match("switch audio output") → "swapout", console_match("please switch audio output for me") → None (won't fire mid-sentence).
sd._terminate / sd._initialize are the same private calls ears.py already depends on.

Docstrings/README/TROUBLESHOOTING get a line each; config.py's DISCIPLINE blurb should list the phrase so a voice session can hand it to the user.

An output_device config key mirroring mic_device would be a reasonable addition alongside this, for people who always want the voice on a specific device regardless of the system default — separate ask.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions