spacr.qt.sound

Play spaCR’s sounds: off by default, rendered off the GUI thread, or silent.

The sounds themselves are synthesized by spacr.qt.sound_synth; this module decides when one plays and hands it to the operating system through Qt Multimedia’s QSoundEffect.

Three rules shape everything here, and each is a measurement rather than a preference:

  • Nothing exists while sound is off. The master switch defaults to off, and until somebody turns it on apply_sound_preferences() returns before constructing anything: no thread, no event filter, no Qt Multimedia import. An application-wide event filter costs every event in the process (measured at 0.93 us per event per filter), so the filter is installed only while a click or hover sound is actually wanted, and removed the moment neither is.

  • Qt Multimedia is touched only on the GUI thread. _SoundPlayer owns QMediaDevices and every QSoundEffect and lives where the application’s event loop does; the dedicated QThread keeps the part that is genuinely slow, which is synthesizing a sound set (_SoundRenderer, numpy and file writes, nothing from Qt Multimedia). Connecting to the sound server is slow – 354 ms measured on a PipeWire workstation – so it is paid ONCE, on an idle timer after sound is switched on (SoundEngine._warm()), and logged rather than felt. Playing an already loaded sound is play(), which returns at once: 0.007-0.014 ms on the GUI thread against the real stack.

    This rule is written in a crash. Building the effects on the audio thread instead does move the connection off the GUI thread – but Qt Multimedia’s device handling belongs to the thread that owns the event loop, so with the FFmpeg backend it enables socket notifiers from the wrong thread. Reopening Preferences with sound on then segfaults, or wedges the interface with an empty Preferences frame until it is force quit.

  • A missing sound stack is silence. No device, no server, no Qt Multimedia, a file that will not load: each ends in a debug log line and no sound. Nothing here raises into a caller and nothing opens a dialog.

The hover sound is the risky one, so it is debounced twice: the pointer must rest on an enabled control for HOVER_SETTLE_MS before anything is considered, and a hover sound never follows another within HOVER_COOLDOWN_S or a click within HOVER_AFTER_CLICK_S.

Classes

InputSoundFilter

Application-wide filter that hears presses and hovers on controls.

SoundEngine

Decides when a sound plays, and plays it on the thread it belongs to.

SoundSettings

What the user has asked to hear, read once and handed around whole.

Functions

announce_run_end(→ bool)

Play the run-finished or run-failed sound for a run that ended.

apply_sound_preferences(→ Optional[SoundEngine])

Bring sound in line with the stored preferences.

perceived_gain(→ float)

Map a slider fraction to a linear gain that sounds evenly spaced.

preview_sound(→ bool)

Play one sound for a Preview button, starting the engine if needed.

read_sound_settings(→ SoundSettings)

Read every sound preference into one SoundSettings.

shutdown_sound(→ bool)

Stop the process's engine, if there is one, and forget it.

sound_engine(→ Optional[SoundEngine])

The running engine, or None when sound was never switched on.

stop_sound_preview(→ None)

End any preview and return to the stored settings.

wav_seconds(→ float)

How long a WAV is, from its header alone.

Module Contents

class spacr.qt.sound.InputSoundFilter(engine: SoundEngine)[source]

Bases: PySide6.QtCore.QObject

Application-wide filter that hears presses and hovers on controls.

Installed only while a click or hover sound is wanted (see SoundEngine.apply()). The body is two liveness checks and at most four comparisons for an event it does not want.

Parameters:

engine – the SoundEngine to tell.

Remember the engine and the kinds of control that make sounds.

eventFilter(watched, event) → bool[source]

Tell the engine about presses and hovers; never consume anything.

A control carrying SILENT_PRESS_PROPERTY is pressed without a click sound. The property is read only for a left press on something pressable, so it costs nothing on the events the filter already ignores.

Parameters:
  • watched – the object the event is for.

  • event – the event.

Returns:

always False, so every event continues as it would have.

class spacr.qt.sound.SoundEngine(parent: PySide6.QtCore.QObject | None = None, *, threaded: bool = True, effect_factory: Callable | None = None, cache_root: pathlib.Path | None = None)[source]

Bases: PySide6.QtCore.QObject

Decides when a sound plays, and plays it on the thread it belongs to.

Two halves, and which thread each lives on is what keeps sound safe. _SoundPlayer holds every Qt Multimedia object and lives here, on the GUI thread, because that is the thread that owns the event loop Qt Multimedia’s device handling attaches to. _SoundRenderer lives on a dedicated QThread and synthesizes sound files, which is the only part that is slow enough to need one.

Parameters:
  • parent – owning object, normally the application.

  • threaded – run the renderer on its own QThread. False runs it inline, emitting the same calls in the same order, so a test can drive the engine synchronously.

  • effect_factory – see _SoundPlayer.

  • cache_root – see _SoundRenderer.

Start the audio thread (when threaded) and wire the requests.

apply(settings: SoundSettings) → None[source]

Follow settings: filter, loaded sounds and music bed.

Switching the master off removes the filter and releases every effect, so a disabled engine holds no audio device. The thread itself stays, idle, until the application quits.

Switching it ON also schedules the one device connection, on a timer rather than now: see _warm().

Parameters:

settings – what to follow from now on.

audio_thread() → PySide6.QtCore.QThread | None[source]

The thread sounds are rendered on, or None when unthreaded.

play(event: str) → bool[source]

Play event’s sound if the settings want it.

Parameters:

event – "click", "hover", "run_finished" or "run_failed".

Returns:

True when a sound was requested.

player() → _SoundPlayer[source]

The GUI-thread half: every Qt Multimedia object lives here.

preview(event: str, theme_key: str, volume: float, music: str = '') → bool[source]

Play event once because the user pressed its Preview button.

Plays whatever the stored switches say: the dialog enables Preview only while its own master switch is on, and the press is the ask.

Parameters:
  • event – any event, the music bed included.

  • theme_key – the sound set chosen in the dialog.

  • volume – the dialog’s volume slider as a fraction.

  • music – the music file named on the page, unsaved. A Preview has to play what the page says and not what the store says, or it is a preview of something else.

Returns:

True when a sound was requested.

shutdown(timeout_ms: int = SHUTDOWN_WAIT_MS) → bool[source]

Stop everything and end the audio thread. Idempotent.

Connected to aboutToQuit. A render in progress is abandoned at its next file boundary; a thread that still will not stop in time is parked by spacr.qt.bridge.drain_thread() rather than terminated.

Parameters:

timeout_ms – how long to wait for the thread.

Returns:

True when the thread has stopped.

stop_preview() → None[source]

Put the music bed back to what the stored settings say.

property closed: bool[source]

Whether shutdown() has run.

property filter_installed: bool[source]

Whether the application-wide input filter is in place.

property settings: SoundSettings[source]

The settings this engine is following.

class spacr.qt.sound.SoundSettings[source]

What the user has asked to hear, read once and handed around whole.

Parameters:
  • enabled – the master switch.

  • volume – master volume as a slider fraction, 0 to 1.

  • theme – key of the sound set in spacr.qt.sound_synth.SOUND_THEMES.

  • click – play a sound when a control is pressed.

  • hover – play a sound when the pointer rests on a control.

  • run_finished – play a sound when a run finishes successfully.

  • run_failed – play a sound when a run stops with an error.

  • bed – play the looping music bed. Already False here when the performance level rests it; see read_sound_settings().

  • music – a WAV of the user’s own to play as the bed instead of the synthesized one. Empty means spaCR’s own. It is also what the Resonance backdrop is driven by, because the backdrop follows WHATEVER IS PLAYING and there is only ever one thing.

wants(event: str) → bool[source]

Whether event should make a sound now.

Parameters:

event – "click", "hover", "run_finished", "run_failed" or "bed".

Returns:

True only when the master switch and the event’s own switch are both on.

property gain: float[source]

The linear gain the volume slider stands for.

property watches_input: bool[source]

Whether the application-wide input filter is needed at all.

spacr.qt.sound.announce_run_end(status: str) → bool[source]

Play the run-finished or run-failed sound for a run that ended.

A run the user stopped is silent: they already know.

Parameters:

status – "success", "failed" or "cancelled".

Returns:

True when a sound was requested.

spacr.qt.sound.apply_sound_preferences(app=None, settings: SoundSettings | None = None) → SoundEngine | None[source]

Bring sound in line with the stored preferences.

Called from spacr.qt.preferences.apply_preferences_to_app(), at launch and after every Save. While sound has never been switched on in this process it reads one setting and returns: nothing is imported or built for a user who has not asked for sound.

Parameters:
  • app – the application; the running instance when omitted.

  • settings – settings to follow instead of the stored ones.

Returns:

the engine, or None when there is none.

spacr.qt.sound.perceived_gain(fraction: float) → float[source]

Map a slider fraction to a linear gain that sounds evenly spaced.

Loudness is heard roughly logarithmically, so a linear slider would do all its audible work in its bottom quarter. The square is the usual cheap fit.

Parameters:

fraction – slider position, 0 to 1; clamped.

Returns:

gain, 0 to 1.

spacr.qt.sound.preview_sound(event: str, theme_key: str, volume: float, app=None, music: str = '') → bool[source]

Play one sound for a Preview button, starting the engine if needed.

Parameters:
  • event – any event, the music bed included.

  • theme_key – the sound set chosen in the dialog.

  • volume – the dialog’s volume slider as a fraction.

  • app – the application; the running instance when omitted.

  • music – the music file named on the page, unsaved.

Returns:

True when a sound was requested.

spacr.qt.sound.read_sound_settings() → SoundSettings[source]

Read every sound preference into one SoundSettings.

The music bed is reported off at the performance levels that rest it, whatever its own switch says, so nothing downstream has to know those levels exist.

Returns:

the settings as stored now.

spacr.qt.sound.shutdown_sound(timeout_ms: int = SHUTDOWN_WAIT_MS) → bool[source]

Stop the process’s engine, if there is one, and forget it.

Parameters:

timeout_ms – how long to wait for the audio thread.

Returns:

True when nothing is left running.

spacr.qt.sound.sound_engine() → SoundEngine | None[source]

The running engine, or None when sound was never switched on.

spacr.qt.sound.stop_sound_preview() → None[source]

End any preview and return to the stored settings.

spacr.qt.sound.wav_seconds(path) → float[source]

How long a WAV is, from its header alone.

The Resonance backdrop needs the loop’s length to work out where in it the playback is, and the header is the cheapest true answer – no samples are read. A file that cannot be opened is reported as zero seconds, which reads downstream as “nothing is playing”.

Parameters:

path – the file.

Returns:

seconds, or 0.0.