Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ ASSISTANT_NUMBER=5550000
# ---------------------------------------------------------------------------

HOOK_SWITCH_PIN=17
SD_AMP_PIN=22
PULSE_SWITCH_PIN=27

# ---------------------------------------------------------------------------
Expand All @@ -40,7 +41,7 @@ PULSE_SWITCH_PIN=27

# ALSA device name passed to aplay -D. Use "plughw:MAX98357A" for the
# I2S amplifier or "default" to route through PipeWire/PulseAudio.
ALSA_DEVICE=plughw:MAX98357A
ALSA_DEVICE=plughw:2,0

# Software volume multiplier (0.0–1.0). Lower if the amp clips or buzzes.
# The MAX98357A with floating GAIN pin has 15 dB hardware gain, so 0.4 is
Expand Down
15 changes: 0 additions & 15 deletions config.env.local
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,6 @@
# Copy to /etc/hello-operator/config.env and fill in values before starting.
# Lines beginning with # are comments and are ignored.

# ---------------------------------------------------------------------------
# Plex (required)
# ---------------------------------------------------------------------------

# Full URL of your Plex Media Server
PLEX_URL=http://localhost:32400

# Plex authentication token
# Find yours at: https://support.plex.tv/articles/204059436
PLEX_TOKEN=

# Machine identifier of the Plex player to control
# Find yours in Plex Settings → Troubleshooting → "Download logs" or via the API
PLEX_PLAYER_IDENTIFIER=

# ---------------------------------------------------------------------------
# Phone system (required)
# ---------------------------------------------------------------------------
Expand Down
19 changes: 7 additions & 12 deletions docs/AMP_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,21 +165,16 @@ Power-cycle the board after changing the gain wiring.

## Channel selection (SD pin)

For reference, the SD pin voltage ranges are:

1. Wire MAX98357A **SD** to **GPIO 22** (physical pin 15) instead of Vin.
2. Set `AMP_SD_PIN=22` in `/etc/hello-operator/config.env`.
The breakout board's SD pin has a 1MΩ pull-up to Vin, which selects **stereo average** output `(L+R)/2` by default — appropriate for mono use with a stereo audio source.

hello-operator handles the rest automatically at startup.

### SD pin voltage reference
For reference, the SD pin voltage ranges are:

| SD pin voltage | Outage |
| SD pin voltage | Output |
|---|---|
| < 0.16 V | Shutdown |
| 0.16 V – 0.77 V | Stereo average (L+R)/2 |
| 0.77 V – 1.4 V | Right channel only |
| > 1.4 V | Left channel only |
| < 0.16V | Amplifier shutdown |
| 0.16V – 0.77V | Stereo average (L+R)/2 |
| 0.77V – 1.4V | Right channel only |
| > 1.4V | Left channel only |

The SD pin is also used for instant audio cutoff — see Step 5 below.

Expand Down
22 changes: 11 additions & 11 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,17 @@ echo ""
# System packages
# ---------------------------------------------------------------------------

echo "==> Installing system packages..."
apt-get update -qq
apt-get install -y \
python3 \
python3-venv \
python3-pip \
alsa-utils \
rtl-sdr \
mpd \
mpc \
mopidy
# echo "==> Installing system packages..."
# apt-get update -qq
# apt-get install -y \
# python3 \
# python3-venv \
# python3-pip \
# alsa-utils \
# rtl-sdr \
# mpd \
# mpc \
# mopidy

# ---------------------------------------------------------------------------
# Config directory and files
Expand Down
89 changes: 72 additions & 17 deletions src/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"""

import io
import logging
import queue
import subprocess
import threading
Expand All @@ -26,6 +27,8 @@

from src.interfaces import AudioInterface

log = logging.getLogger(__name__)

# Sample rate for the persistent aplay stream. All audio is converted to
# this rate before being written.
_SAMPLE_RATE = 44100
Expand Down Expand Up @@ -97,7 +100,8 @@ class SounddeviceAudio(AudioInterface):
"""

def __init__(self, sample_rate: int = _SAMPLE_RATE, device: str = "default",
volume: float = 1.0, _popen=None) -> None:
volume: float = 1.0, sd_pin: int = None,
_popen=None, _gpio_output=None) -> None:
self._sample_rate = sample_rate
self._device = device
self._volume = max(0.0, min(1.0, volume))
Expand All @@ -107,20 +111,37 @@ def __init__(self, sample_rate: int = _SAMPLE_RATE, device: str = "default",
self._queue: queue.Queue = queue.Queue()
self._busy = False

self._proc = self._popen(
['aplay', '-q', '-D', self._device,
'-f', 'S16_LE', '-r', str(self._sample_rate), '-c', '1'],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)

_warmup_frames = int(self._sample_rate * _WARMUP_MS / 1000)
_warmup_silence = np.zeros(_warmup_frames, dtype=np.int16).tobytes()
try:
self._proc.stdin.write(_warmup_silence)
except (BrokenPipeError, OSError):
pass
# SD pin: drive LOW to cut amp instantly; release to INPUT to re-enable.
# Both callables are None when sd_pin is not configured.
self._amp_off = None
self._amp_on = None
if sd_pin is not None:
if _gpio_output is not None:
self._amp_off = lambda: _gpio_output(sd_pin, False)
self._amp_on = lambda: _gpio_output(sd_pin, None)
else:
try:
import RPi.GPIO as GPIO # type: ignore[import]
_pin = sd_pin
GPIO.setmode(GPIO.BCM)
GPIO.setup(_pin, GPIO.IN)

def _amp_off(_p=_pin, _g=GPIO):
log.info("SD pin %d → OUTPUT LOW (amp off)", _p)
_g.setup(_p, _g.OUT)
_g.output(_p, _g.LOW)

def _amp_on(_p=_pin, _g=GPIO):
log.debug("SD pin %d → INPUT (amp on)", _p)
_g.setup(_p, _g.IN)

self._amp_off = _amp_off
self._amp_on = _amp_on
log.info("SD amp pin configured: BCM %d", sd_pin)
except Exception as exc:
log.warning("SD amp pin setup failed (pin %d): %s", sd_pin, exc)

self._proc = None

self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
self._worker_thread.start()
Expand Down Expand Up @@ -157,6 +178,37 @@ def play_off_hook_tone(self) -> None:
pcm = self._waveform_to_pcm(waveform)
self._enqueue(lambda: self._write_pcm_loop(pcm))

def amp_off(self) -> None:
"""Cut the amp and terminate aplay. Call only from the hook watcher."""
if self._amp_off:
self._amp_off()
self.stop()
proc, self._proc = self._proc, None
if proc is not None:
try:
proc.terminate()
except OSError:
pass

def amp_on(self) -> None:
"""Start aplay and enable the amp. Call only from the hook watcher."""
if self._proc is None or self._proc.poll() is not None:
self._proc = self._popen(
['aplay', '-q', '-D', self._device,
'-f', 'S16_LE', '-r', str(self._sample_rate), '-c', '1'],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_warmup_frames = int(self._sample_rate * _WARMUP_MS / 1000)
_warmup_silence = np.zeros(_warmup_frames, dtype=np.int16).tobytes()
try:
self._proc.stdin.write(_warmup_silence)
except (BrokenPipeError, OSError):
pass
if self._amp_on:
self._amp_on()

def stop(self) -> None:
"""Stop current playback and clear all queued tasks.

Expand Down Expand Up @@ -209,9 +261,12 @@ def _worker_loop(self) -> None:
self._busy = False

def _write_raw(self, pcm: bytes) -> None:
"""Write raw PCM bytes to the persistent aplay stdin; swallow broken pipe."""
"""Write raw PCM bytes to aplay stdin; no-op if aplay is not running."""
proc = self._proc
if proc is None:
return
try:
self._proc.stdin.write(pcm)
proc.stdin.write(pcm)
except (BrokenPipeError, OSError):
pass

Expand Down
6 changes: 4 additions & 2 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# Audio output device (ALSA device name passed to aplay -D)
# ---------------------------------------------------------------------------

ALSA_DEVICE = os.environ.get("ALSA_DEVICE", "plughw:MAX98357A")
ALSA_DEVICE = os.environ.get("ALSA_DEVICE", "hw:2,0")

# Software volume multiplier applied to all audio output (0.0–1.0).
# Reduce if the amp clips or buzzes; increase if output is too quiet.
Expand All @@ -39,7 +39,7 @@
# Timing constants (in seconds unless noted)
# ---------------------------------------------------------------------------

DIAL_TONE_TIMEOUT_IDLE = 5 # Silence before idle operator prompt
DIAL_TONE_TIMEOUT_IDLE = 3 # Silence before idle operator prompt
DIAL_TONE_TIMEOUT_PLAYING = 2 # Silence before playing-state prompt
INTER_DIGIT_TIMEOUT = 0.3 # Gap after last pulse → digit complete (300 ms)
DIRECT_DIAL_DISAMBIGUATION_TIMEOUT = 1.5 # TODO: tune on hardware — wait after first digit before treating as single nav input
Expand Down Expand Up @@ -84,6 +84,8 @@
# GPIO pin assignments (BCM numbering) — optional; defaults match recommended wiring docs
HOOK_SWITCH_PIN = int(os.environ.get("HOOK_SWITCH_PIN", "17"))
PULSE_SWITCH_PIN = int(os.environ.get("PULSE_SWITCH_PIN", "27"))
_sd_amp_pin_env = os.environ.get("SD_AMP_PIN")
SD_AMP_PIN: int | None = int(_sd_amp_pin_env) if _sd_amp_pin_env is not None else None

# Radio configuration
RADIO_CONFIG_PATH = "/etc/hello-operator/radio_stations.json"
Expand Down
Loading