diff --git a/.gitignore b/.gitignore index e571e019..d26e6caa 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,6 @@ myenv # installation flags and installed libraries installers/i2s_mic_installed.flag installers/i2s_speech_recognition_installed.flag +installers/audio/backups/* speech.wav diff --git a/environments/cody.yml b/environments/cody.yml index a206e317..feeffbb3 100644 --- a/environments/cody.yml +++ b/environments/cody.yml @@ -386,7 +386,7 @@ personality: pitemperature: enabled: true -tft_display: +tft_display_eye: enabled: true config: bus: 0 # Use `ls /dev/spi*` to identify. (/dev/spidev0.0) is bus 0, device 0 diff --git a/environments/voice.yml b/environments/voice.yml new file mode 100644 index 00000000..17a635b9 --- /dev/null +++ b/environments/voice.yml @@ -0,0 +1,32 @@ +# Laptop — local development and testing environment with no hardware modules. +# Useful for developing, testing, and running the Discord bot locally. +# Matches modules configured with environment: 'laptop' or without 'robot' restriction. + +# ── Core ────────────────────────────────────────────────────────────────────── + +messaging_service: + enabled: true + +logwrapper: + enabled: true + +speechinput: + enabled: true + config: + # wake_word: ['cody','kodi'] # if set, will only process speech that contains this word. Can be used to avoid processing random speech in the environment. + start_on_boot: true + +chatgpt: + enabled: false + +rsvp_display: + enabled: true + config: + rotation: -90.0 + font_size: 30 + wpm: 300 + test_on_boot: true + +ttsmodule: + enabled: false # need to add credit + diff --git a/install_audio.sh b/install_audio.sh new file mode 100644 index 00000000..3809a516 --- /dev/null +++ b/install_audio.sh @@ -0,0 +1,22 @@ + +sudo python3 -m pip install --upgrade click --break-system-packages + +sudo python3 -m pip install --upgrade setuptools --break-system-packages +sudo python3 -m pip install --upgrade adafruit-python-shell --break-system-packages + +sudo python3 installers/i2samp.py + +# Example aplay -l output on CM5 before googlevoicehat overlay takes effect (first boot after install): +# card 0: vc4hdmi0 [vc4-hdmi-0], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0] +# card 1: vc4hdmi1 [vc4-hdmi-1], device 0: MAI PCM i2s-hifi-0 [MAI PCM i2s-hifi-0] +# card 2: I2S [Dual I2S], device 0: 1f000a0000.i2s-dit-hifi dit-hifi-0 [...] +# +# After reboot with googlevoicehat overlay active - card numbers can vary between boots! +# card 0: vc4hdmi0 [vc4-hdmi-0], ... +# card 1: vc4hdmi1 [vc4-hdmi-1], ... +# card 2: sndrpigooglevoi [snd_rpi_googlevoicehat_soundcar], device 0: Google voiceHAT SoundCard HiFi ... +# OR (on a subsequent restart): +# card 1: sndrpigooglevoi [snd_rpi_googlevoicehat_soundcar], device 0: Google voiceHAT SoundCard HiFi ... +# card 2: vc4hdmi1 [vc4-hdmi-1], ... +# +# This is why asound.conf must reference the card by NAME (sndrpigooglevoi) not by number. diff --git a/installers/audio/Audio.md b/installers/audio/Audio.md new file mode 100644 index 00000000..94e626af --- /dev/null +++ b/installers/audio/Audio.md @@ -0,0 +1,163 @@ +# I2S Audio — CM5 Carrier Board + +## Hardware + +| Signal | GPIO | Connected to | +|--------|------|--------------| +| BCLK (I2S clock) | 18 | MAX98357A + ICS-43434 | +| LRCLK (word select) | 19 | MAX98357A + ICS-43434 | +| SDO (TX, data out) | 21 | MAX98357A DIN | +| SDI (RX, data in) | 20 | ICS-43434 DATA (both mics) | +| SD_MODE (amp enable) | 16 | MAX98357A SD | + +Two ICS-43434 I2S microphones share the same DATA line (GPIO 20). One has its L/R SELECT pin tied to GND (left channel), the other to 3.3 V (right channel), so they form a stereo pair on a single wire. + +The CM5 I2S controller is the Synopsys DesignWare (`snps,designware-i2s`) on the RP1 chip. It supports **`S32_LE` at `48000 Hz` only**. + +--- + +## Device Tree Overlay + +The kernel learns about hardware connections through a **Device Tree overlay** — a small binary patch applied at boot that tells the kernel which drivers to load and how to wire them together. + +### Files + +| File | Purpose | +|------|---------| +| `installers/audio/cm5-i2s-audio.dts` | Human-readable source describing the hardware | +| `cm5-i2s-audio.dtbo` (compiled from above) | Binary patch applied by the bootloader — installed to `/boot/firmware/overlays/` | + +The DTS source creates a `simple-audio-card` with **two DAI (Digital Audio Interface) links** on the same I2S controller: + +- **DAI link 0** → `invensense,ics43432` codec driver → ALSA PCM **device 0** (capture) +- **DAI link 1** → `maxim,max98357a` codec driver → ALSA PCM **device 1** (playback) + +### Why not `googlevoicehat-soundcard`? + +The `voicehat-codec` driver only defines a speaker output in its DAPM (Dynamic Audio Power Management) routing. DAPM is how the kernel knows which hardware paths to power up for a given operation. Because the voicehat codec had no capture DAPM route, the RP1's I2S RX engine was never enabled — microphone recordings returned silence regardless of wiring. + +The new overlay uses dedicated drivers (`max98357a`, `ics43432`) that have correct DAPM routes for both playback and capture. + +### Installing the overlay + +`install-audio.sh` (run from the `installers/` directory on the device) compiles the DTS and installs it: + +```bash +cd ~/modular-biped/installers +bash install-audio.sh +sudo reboot +``` + +Requires `dtc` (`sudo apt install device-tree-compiler` if missing). + +### `config.txt` entry + +``` +dtoverlay=cm5-i2s-audio +hdmi_ignore_edid_audio=1 +#dtparam=audio=on ← keep disabled +``` + +--- + +## ALSA Configuration (`/etc/asound.conf`) + +After reboot the sound card appears as: + +``` +card 0: cm5i2saudio [cm5-i2s-audio] + device 0: ics43432-hifi (capture) + device 1: MAX98357A HiFi (playback) +``` + +The ALSA card number can change between boots when HDMI devices are present, so always reference the card by **name** (`cm5i2saudio`). + +`asound.conf` defines a software pipeline on top of the hardware: + +- **`pcm.speakerbonnet`** — raw hardware playback (`hw:cm5i2saudio,1`) +- **`pcm.dmixer`** — software mixer so multiple apps can play simultaneously (`type dmix`) +- **`pcm.softvol`** — software volume control on top of dmixer +- **`pcm.mic_plug`** — capture with format conversion (`type plug` over `hw:cm5i2saudio,0`), so apps that request `S16_LE` are transparently converted to the `S32_LE` the hardware requires +- **`pcm.!default`** — `type asym` routes playback and capture independently (dmix/softvol are playback-only; without `asym`, capture would be blocked) + +On Raspberry Pi OS Bookworm, PipeWire is the default audio server and overrides `pcm.!default` for most applications. The `asound.conf` chain is used by CLI tools (`aplay`, `arecord`) that access ALSA directly. + +--- + +## Testing + +**List devices:** +```bash +aplay -l # shows device 1 (MAX98357A, playback) +arecord -l # shows device 0 (ICS-43434, capture) +``` + +**Speaker test (direct hardware, both channels):** +```bash +speaker-test -D hw:cm5i2saudio,1 -c 2 -r 48000 -t sine -l 1 +``` + +**Microphone test (record 3 s, play back):** +```bash +arecord -D hw:cm5i2saudio,0 -f S32_LE -c 2 -r 48000 -d 3 test.wav +aplay -D hw:cm5i2saudio,1 test.wav +``` + +**Via default device (through PipeWire or asound.conf chain):** +```bash +arecord -D default -f S16_LE -c 2 -r 48000 -d 3 /tmp/test.wav +aplay -D default /tmp/test.wav +``` + +--- + +## Speech Recognition (`SpeechInput` module) + +The `SpeechInput` module uses the `speech_recognition` library, which requests audio at **16000 Hz**. The hardware only supports 48000 Hz, so a device that handles resampling is required. + +The module is configured to use the `pulse` device, which is PipeWire's PulseAudio compatibility interface. This is the correct choice because: + +- The direct hardware device (`hw:cm5i2saudio,0`) rejects any sample rate other than 48000 Hz — `speech_recognition`'s 16000 Hz request will fail immediately. +- The `pulse` device routes through PipeWire, which resamples transparently between whatever the application requests and what the hardware needs. + +--- + +## `aplay.service` + +This service was originally used to hold the hardware open to prevent a pop on first playback. **Do not enable it on PipeWire systems** — it holds an exclusive ALSA dmix lock and causes PipeWire to time out. `install-audio.sh` explicitly disables it. The service file is kept in the repo only as a reference for pure-ALSA setups. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| `aplay -l` shows no `cm5i2saudio` card | Overlay not applied | Check `dmesg \| grep -i "i2s\|max98\|ics43"` for probe errors; confirm `/boot/firmware/overlays/cm5-i2s-audio.dtbo` exists | +| Speaker silent, no errors | SD_MODE GPIO wrong | Verify GPIO 16 is correct for the carrier board; check `pinctrl get 16` | +| Microphone records silence | DAPM not routing capture | Confirm you are using `cm5-i2s-audio` overlay (not `googlevoicehat-soundcard`); check `sudo find /sys/kernel/debug/asoc -name "Capture"` exists | +| `arecord` error: unsupported format | Wrong format for hw device | Use `-f S32_LE` with `hw:cm5i2saudio,0`; use `-f S16_LE` with `default` (plug converts) | +| PipeWire stream timeout | `aplay.service` is running | `sudo systemctl disable --now aplay.service` | + +--- + +## Terms + +**Device Tree** — A data structure the bootloader passes to the Linux kernel describing hardware that cannot be auto-detected (I2S buses, GPIO assignments, codec chip connections, etc.). + +**DTS (Device Tree Source)** — Human-readable text source file (like `cm5-i2s-audio.dts`). + +**DTB (Device Tree Blob)** — The compiled binary form of a DTS, loaded at boot. + +**DTBO (Device Tree Blob Overlay)** — A partial DTB patched on top of the base device tree at boot. Pi overlays are `.dtbo` files in `/boot/firmware/overlays/`. `dtoverlay=name` in `config.txt` loads `name.dtbo`. + +**DAPM (Dynamic Audio Power Management)** — The ASoC (ALSA System on Chip) subsystem that manages which hardware audio paths are powered at any time. A codec driver must define DAPM widgets and routes for both playback and capture; if a capture route is missing, the I2S RX engine is never enabled even if the hardware supports it. + +**DAI (Digital Audio Interface)** — The connection between the CPU-side I2S controller and a codec chip. A sound card can have multiple DAI links (one per codec), each appearing as a separate PCM device in ALSA. + +**dmix** — ALSA plugin that software-mixes multiple playback streams so more than one app can play audio simultaneously. + +**softvol** — ALSA plugin providing software volume control for hardware that has no built-in mixer. + +**asym** — ALSA plugin that routes playback and capture to independent devices, needed when the playback chain (dmix/softvol) cannot handle capture. + +**plug** — ALSA plugin that transparently converts sample format and rate between what an application requests and what the hardware supports (e.g. S16_LE → S32_LE). diff --git a/installers/audio/aplay.service b/installers/audio/aplay.service new file mode 100644 index 00000000..c20ba24c --- /dev/null +++ b/installers/audio/aplay.service @@ -0,0 +1,9 @@ + +[Unit] +Description=Invoke aplay from /dev/zero at system start. + +[Service] +ExecStart=/usr/bin/aplay -D default -t raw -r 48000 -c 2 -f S32_LE /dev/zero + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/installers/audio/archive/i2samp.py b/installers/audio/archive/i2samp.py new file mode 100644 index 00000000..e332ca70 --- /dev/null +++ b/installers/audio/archive/i2samp.py @@ -0,0 +1,148 @@ +import os + +try: + from adafruit_shell import Shell + from clint.textui import colored +except ImportError: + raise RuntimeError("The library 'adafruit_shell' was not found. To install, try typing: sudo pip3 install adafruit-python-shell") + +shell = Shell() + +BLACKLIST = "/etc/modprobe.d/raspi-blacklist.conf" +PRODUCT_NAME = "I2S Amplifier" +OVERLAY = "googlevoicehat-soundcard" + +def driver_loaded(driver_name): + return shell.run_command(f"lsmod | grep -q '{driver_name}'", suppress_message=True) + +def main(): + reboot = False + shell.clear() + if not shell.is_raspberry_pi(): + shell.bail("Non-Raspberry Pi board detected.") + print("\nThis script will install everything needed to use\n" + f"{PRODUCT_NAME}.\n") + print(colored.red("--- Warning ---")) + print("\nAlways be careful when running scripts and commands\n" + "copied from the internet. Ensure they are from a\n" + "trusted source.\n") + if not shell.prompt("Do you wish to continue?"): + print("\nAborting...") + shell.exit() + + print("\nChecking hardware requirements...") + + # Enable I2S overlay + config = shell.get_boot_config() + if config is None: + shell.bail("No Device Tree Detected, not supported") + + print(f"\nAdding Device Tree Entry to {config}") + + if shell.pattern_search(config, f"^dtoverlay={OVERLAY}$"): + print("dtoverlay already active") + else: + shell.write_text_file(config, f"dtoverlay={OVERLAY}") + reboot = True + + if os.path.exists(BLACKLIST): + print("\nCommenting out Blacklist entry in", BLACKLIST) + shell.pattern_replace(BLACKLIST, "^blacklist[[:space:]]*snd_soc_max98357a.*", "#blacklist snd_soc_max98357a") + shell.pattern_replace(BLACKLIST, "^blacklist[[:space:]]*snd_soc_max98357a_i2c.*", "#blacklist snd_soc_max98357a_i2c") + shell.pattern_replace(BLACKLIST, "^blacklist[[:space:]]*snd_soc_max98357a.*", "#blacklist snd_soc_max98357a") + + print("Configuring sound output") + if os.path.exists("/etc/asound.conf"): + if os.path.exists("/etc/asound.conf.old"): + shell.remove("/etc/asound.conf.old") + shell.move("/etc/asound.conf", "/etc/asound.conf.old") + shell.write_text_file("~/asound.conf", +""" +# Use the card name (sndrpigooglevoi) instead of a hardcoded card number. +# The ALSA card number for the I2S device can change between boots when HDMI +# devices are present, so referencing by name ensures the correct device is +# always targeted. + +pcm.speakerbonnet { + type hw + card sndrpigooglevoi +} + +pcm.dmixer { + type dmix + ipc_key 1024 + ipc_perm 0666 + slave { + pcm "speakerbonnet" + period_time 0 + period_size 1024 + buffer_size 8192 + rate 44100 + channels 2 + } +} + +ctl.dmixer { + type hw + card sndrpigooglevoi +} + +pcm.softvol { + type softvol + slave.pcm "dmixer" + control.name "PCM" + control.card sndrpigooglevoi +} + +ctl.softvol { + type hw + card sndrpigooglevoi +} + +# Separate playback and capture defaults using asym so that: +# - aplay / speaker output uses the softvol/dmix chain +# - arecord / microphone input uses the I2S capture device directly +pcm.!default { + type asym + playback.pcm "softvol" + capture.pcm "hw:sndrpigooglevoi" +} +""") + shell.move("~/asound.conf", "/etc/asound.conf") + + + print("Installing aplay systemd unit") + shell.write_text_file("/etc/systemd/system/aplay.service", """ +[Unit] +Description=Invoke aplay from /dev/zero at system start. + +[Service] +ExecStart=/usr/bin/aplay -D default -t raw -r 44100 -c 2 -f S16_LE /dev/zero + +[Install] +WantedBy=multi-user.target""", append=False) + + shell.run_command("sudo systemctl daemon-reload") + shell.run_command("sudo systemctl disable aplay") + print("\nYou can optionally activate '/dev/zero' playback in\n" + "the background at boot. This will remove all\n" + "popping/clicking but does use some processor time.\n\n") + if shell.prompt("Activate '/dev/zero' playback in background? [RECOMMENDED]\n", default="y"): + shell.run_command("sudo systemctl enable aplay") + reboot = True + + if driver_loaded("max98357a"): + print(f"\nWe can now test your {PRODUCT_NAME}") + shell.warn("Set your speakers at a low volume if possible!") + if shell.prompt("Do you wish to test your system now?"): + print("Testing...") + shell.run_command("speaker-test -l5 -c2 -t wav") + print("\n" + colored.green("All done!")) + print(f"\nEnjoy your new {PRODUCT_NAME}!") + if reboot: + shell.prompt_reboot() + +# Main function +if __name__ == "__main__": + shell.require_root() + main() diff --git a/installers/audio/archive/i2smic.py b/installers/audio/archive/i2smic.py new file mode 100644 index 00000000..bed3c188 --- /dev/null +++ b/installers/audio/archive/i2smic.py @@ -0,0 +1,78 @@ +import os + +try: + from adafruit_shell import Shell +except ImportError: + raise RuntimeError("The library 'adafruit_shell' was not found. To install, try typing: sudo pip3 install adafruit-python-shell") + +shell = Shell() + +def main(): + shell.clear() + boot_config = shell.get_boot_config() + print("""This script downloads and installs +I2S microphone support. +""") + if not shell.is_raspberry_pi(): + shell.bail("Non-Raspberry Pi board detected.") + pi_model = shell.get_board_model() + print("{} detected.\n".format(pi_model)) + if pi_model in ("RASPBERRY_PI_ZERO", "RASPBERRY_PI_ZERO_W", "RASPBERRY_PI_B_REV2"): + pimodel_select = 0 + elif pi_model in ("RASPBERRY_PI_2B", "RASPBERRY_PI_3B", "RASPBERRY_PI_CM3_PLUS", "RASPBERRY_PI_3B_PLUS", "RASPBERRY_PI_3A_PLUS", "RASPBERRY_PI_ZERO_2_W"): + pimodel_select = 1 + elif pi_model in ("RASPBERRY_PI_4B", "RASPBERRY_PI_CM4", "RASPBERRY_PI_400", + "RASPBERRY_PI_5B", "RASPBERRY_PI_CM5", "RASPBERRY_PI_5"): + pimodel_select = 2 + else: + shell.bail("Unsupported Pi board detected.") + + auto_load = ( + not shell.argument_exists('noautoload') and + shell.prompt("Auto load module at boot?", force_arg="autoload")) + + print(""" +Installing...""") + + # Get needed packages + shell.run_command("apt-get -y install git raspberrypi-kernel-headers") + + # Clone the repo + shell.run_command("git clone https://github.com/adafruit/Raspberry-Pi-Installer-Scripts.git") + + # Build and install the module + shell.chdir("Raspberry-Pi-Installer-Scripts/i2s_mic_module") + shell.run_command("make clean") + shell.run_command("make") + shell.run_command("make install") + + # Setup auto load at boot if selected + if auto_load: + shell.write_text_file( + "/etc/modules-load.d/snd-i2smic-rpi.conf", + "snd-i2smic-rpi" + ) + shell.write_text_file( + "/etc/modprobe.d/snd-i2smic-rpi.conf", + "options snd-i2smic-rpi rpi_platform_generation={}".format(pimodel_select) + ) + + # Enable I2S overlay + shell.run_command(f"sed -i -e 's/#dtparam=i2s/dtparam=i2s/g' {boot_config}") + + # Done + print("""DONE. + +Settings take effect on next boot. +""") + if not shell.argument_exists('noreboot'): + shell.prompt_reboot(force_arg="reboot") + +# Main function +if __name__ == "__main__": + shell.require_root() + # Probably not necessary for now because the Pi 5 doesn't work anyway + if shell.is_raspberry_pi_os() and shell.is_kernel_userspace_mismatched() and shell.is_pi5_or_newer(): + shell.bail("Unable to proceed on Pi 5 or newer boards with a with a 32-bit OS. Please reinstall with a 64-bit OS.") + shell.check_kernel_userspace_mismatch() + main() diff --git a/installers/install-amp.sh b/installers/audio/archive/install-amp.sh similarity index 100% rename from installers/install-amp.sh rename to installers/audio/archive/install-amp.sh diff --git a/installers/install-mics.sh b/installers/audio/archive/install-mics.sh similarity index 100% rename from installers/install-mics.sh rename to installers/audio/archive/install-mics.sh diff --git a/installers/install-speech-recognition.sh b/installers/audio/archive/install-speech-recognition.sh similarity index 100% rename from installers/install-speech-recognition.sh rename to installers/audio/archive/install-speech-recognition.sh diff --git a/installers/install-spi-tft.sh b/installers/audio/archive/install-spi-tft.sh similarity index 100% rename from installers/install-spi-tft.sh rename to installers/audio/archive/install-spi-tft.sh diff --git a/installers/audio/asound.conf b/installers/audio/asound.conf new file mode 100644 index 00000000..8420ff6f --- /dev/null +++ b/installers/audio/asound.conf @@ -0,0 +1,64 @@ + + +# Card name: cm5i2saudio (ALSA normalises "cm5-i2s-audio" → "cm5i2saudio") +# The custom cm5-i2s-audio overlay creates a simple-audio-card with two DAI links: +# device 1: MAX98357A speaker amplifier (playback only) +# device 0: ICS-43434 stereo mic pair (capture only) +# +# With PipeWire running (default on Bookworm) pcm.!default is overridden by +# PipeWire's ALSA plugin. The definitions below are used by CLI tools +# (aplay, arecord) that access ALSA directly. + +pcm.speakerbonnet { + type hw + card cm5i2saudio + device 1 +} + +pcm.dmixer { + type dmix + ipc_key 1024 + ipc_perm 0666 + slave { + pcm "speakerbonnet" + period_time 0 + period_size 1024 + buffer_size 8192 + rate 48000 + channels 2 + format S32_LE + } +} + +ctl.dmixer { + type hw + card cm5i2saudio +} + +pcm.softvol { + type softvol + slave.pcm "dmixer" + control.name "PCM" + control.card cm5i2saudio +} + +ctl.softvol { + type hw + card cm5i2saudio +} + +# Separate playback and capture defaults using asym so that: +# - aplay / speaker output uses the softvol/dmix chain +# - arecord / microphone input uses the ICS-43434 capture device (hw:X,0) +# plug wrapper allows ALSA to convert format/rate for apps that don't natively +# support S32_LE / 48000 Hz (the only format the DW I2S hardware accepts). +pcm.mic_plug { + type plug + slave.pcm "hw:cm5i2saudio,0" +} + +pcm.!default { + type asym + playback.pcm "softvol" + capture.pcm "mic_plug" +} diff --git a/installers/audio/cm5-i2s-audio.dtbo b/installers/audio/cm5-i2s-audio.dtbo new file mode 100644 index 00000000..5ba8a3f6 Binary files /dev/null and b/installers/audio/cm5-i2s-audio.dtbo differ diff --git a/installers/audio/cm5-i2s-audio.dts b/installers/audio/cm5-i2s-audio.dts new file mode 100644 index 00000000..93602359 --- /dev/null +++ b/installers/audio/cm5-i2s-audio.dts @@ -0,0 +1,94 @@ +// Custom I2S audio overlay for CM5 carrier board +// - MAX98357A speaker amplifier (I2S TX, SD_MODE on GPIO16) +// - 2x ICS-43434 stereo microphones (I2S RX, L/R SELECT pins) +// +// Creates a single simple-audio-card with two DAI links: +// PCM device 0 (hw:cm5-i2s-audio,0): playback via MAX98357A +// PCM device 1 (hw:cm5-i2s-audio,1): capture via ICS-43434 stereo pair +// +// Replaces dtoverlay=googlevoicehat-soundcard in /boot/firmware/config.txt + +/dts-v1/; +/plugin/; + +/ { + compatible = "brcm,bcm2712"; + + // Enable the RP1 I2S controller as clock producer (master) + fragment@0 { + target = <&i2s_clk_producer>; + __overlay__ { + status = "okay"; + }; + }; + + // MAX98357A speaker amplifier node (SD_MODE on GPIO16) + fragment@1 { + target-path = "/"; + __overlay__ { + max98357a_dac: max98357a { + compatible = "maxim,max98357a"; + #sound-dai-cells = <0>; + sdmode-gpios = <&gpio 16 0>; + status = "okay"; + }; + }; + }; + + // ICS-43434 stereo microphone pair (treated as ICS-43432 compatible) + // L/R SELECT: one mic at GND (left), one at 3.3V (right), both on GPIO20 + fragment@2 { + target-path = "/"; + __overlay__ { + ics43434_mics: ics43434-stereo { + compatible = "invensense,ics43432"; + #sound-dai-cells = <0>; + status = "okay"; + }; + }; + }; + + // Sound card: two DAI links share the same I2S controller + // dai-link@0: I2S TX -> MAX98357A (playback, PCM device 0) + // dai-link@1: ICS-43434 -> I2S RX (capture, PCM device 1) + fragment@3 { + target = <&sound>; + __overlay__ { + compatible = "simple-audio-card"; + simple-audio-card,name = "cm5-i2s-audio"; + status = "okay"; + + spk_link: simple-audio-card,dai-link@0 { + format = "i2s"; + bitclock-master = <&spk_cpu>; + frame-master = <&spk_cpu>; + + spk_cpu: cpu { + sound-dai = <&i2s_clk_producer>; + dai-tdm-slot-num = <2>; + dai-tdm-slot-width = <32>; + }; + + spk_codec: codec { + sound-dai = <&max98357a_dac>; + }; + }; + + mic_link: simple-audio-card,dai-link@1 { + format = "i2s"; + bitclock-master = <&mic_cpu>; + frame-master = <&mic_cpu>; + + mic_cpu: cpu { + sound-dai = <&i2s_clk_producer>; + dai-tdm-slot-num = <2>; + dai-tdm-slot-width = <32>; + }; + + mic_codec: codec { + sound-dai = <&ics43434_mics>; + }; + }; + }; + }; +}; diff --git a/installers/audio/config.txt b/installers/audio/config.txt new file mode 100755 index 00000000..96e7c43f --- /dev/null +++ b/installers/audio/config.txt @@ -0,0 +1,59 @@ +# For more options and information see +# http://rptl.io/configtxt +# Some settings may impact device functionality. See link above for details + +# Uncomment some or all of these to enable the optional hardware interfaces +dtparam=i2c_arm=on +dtparam=i2s=on +dtparam=spi=on + +# Enable audio (loads snd_bcm2835) +#dtparam=audio=on + +# Additional overlays and parameters are documented +# /boot/firmware/overlays/README + +# Automatically load overlays for detected cameras +camera_auto_detect=1 + +# Automatically load overlays for detected DSI displays +display_auto_detect=1 + +# Automatically load initramfs files, if found +auto_initramfs=1 + +# Enable DRM VC4 V3D driver +#dtoverlay=vc4-kms-v3d +max_framebuffers=2 + +# Don't have the firmware create an initial video= setting in cmdline.txt. +# Use the kernel's default instead. +disable_fw_kms_setup=1 + +# Run in 64-bit mode +arm_64bit=1 + +# Disable compensation for displays with overscan +disable_overscan=1 + +# Run as fast as firmware / board allows +arm_boost=1 + +[cm4] +# Enable host mode on the 2711 built-in XHCI USB controller. +# This line should be removed if the legacy DWC2 controller is required +# (e.g. for USB device mode) or if USB support is not required. +otg_mode=1 + +[cm5] +dtoverlay=dwc2,dr_mode=host +dtoverlay=imx500-pi5,cam0 + +[all] +dtparam=uart0=on +#dtoverlay=i2s-mmap +#dtoverlay=googlevoicehat-soundcard +#dtoverlay=max98357a +dtoverlay=cm5-i2s-audio +#dtoverlay=dual_i2s +hdmi_ignore_edid_audio=1 diff --git a/installers/audio/raspi-blacklist.conf b/installers/audio/raspi-blacklist.conf new file mode 100644 index 00000000..e69de29b diff --git a/installers/install-audio.sh b/installers/install-audio.sh new file mode 100755 index 00000000..7ec8931f --- /dev/null +++ b/installers/install-audio.sh @@ -0,0 +1,35 @@ +mkdir audio/backups/backup-$(date +%Y-%m-%d-%H-%M-%S) +cd audio/backups/backup-$(date +%Y-%m-%d-%H-%M-%S) +cp /boot/firmware/config.txt config.txt +cp /etc/modprobe.d/raspi-blacklist.conf raspi-blacklist.conf +cp /etc/asound.conf asound.conf +cp /etc/systemd/system/aplay.service aplay.service + +echo "Backed up current audio configuration files to $(pwd)." +echo "Installing audio config." +cd ../../.. + +# Compile and install the custom cm5-i2s-audio device tree overlay. +# This overlay creates a simple-audio-card with proper DAPM routes for both +# the MAX98357A speaker (device 1) and ICS-43434 stereo mics (device 0). +echo "Compiling cm5-i2s-audio device tree overlay..." +if ! command -v dtc &> /dev/null; then + echo "dtc not found — installing device-tree-compiler..." + sudo apt-get install -y device-tree-compiler +fi +dtc -@ -I dts -O dtb -o /tmp/cm5-i2s-audio.dtbo audio/cm5-i2s-audio.dts +sudo cp /tmp/cm5-i2s-audio.dtbo /boot/firmware/overlays/cm5-i2s-audio.dtbo +echo "cm5-i2s-audio.dtbo installed to /boot/firmware/overlays/" + +sudo cp audio/config.txt /boot/firmware/config.txt +sudo cp audio/raspi-blacklist.conf /etc/modprobe.d/raspi-blacklist.conf +sudo cp audio/asound.conf /etc/asound.conf +sudo cp audio/aplay.service /etc/systemd/system/aplay.service + +# Disable aplay.service: it holds the hardware open via ALSA dmix, which +# blocks PipeWire (the default audio server on Bookworm) from accessing the +# device. PipeWire handles device lifecycle and mixing natively. +sudo systemctl disable --now aplay.service 2>/dev/null || true + +echo "Audio configuration files copied. Please reboot the system for changes to take effect." + diff --git a/src/modules/audio/speechinput/config.yml b/src/modules/audio/speechinput/config.yml index b5900864..b1ad46bd 100644 --- a/src/modules/audio/speechinput/config.yml +++ b/src/modules/audio/speechinput/config.yml @@ -1,7 +1,4 @@ speechinput: - config: - device_name: pulse - start_on_boot: false dependencies: unix: - flac @@ -10,6 +7,5 @@ speechinput: python: - SpeechRecognition additional: - - installers/install-mics.sh - - installers/install-speech-recognition.sh + - installers/install-audio.sh class: SpeechInput diff --git a/src/modules/audio/speechinput/speechinput.py b/src/modules/audio/speechinput/speechinput.py index d72c496b..27bb6c2c 100644 --- a/src/modules/audio/speechinput/speechinput.py +++ b/src/modules/audio/speechinput/speechinput.py @@ -1,3 +1,5 @@ +import os +import subprocess import speech_recognition as sr from time import sleep from threading import Thread @@ -9,16 +11,43 @@ class SpeechInput(BaseModule): """ def __init__(self, **kwargs): self.recognizer = sr.Recognizer() - self.recognizer.pause_threshold = 2 - self.device_name = kwargs.get('device_name', 'lp') + # Energy threshold: minimum audio level considered speech. + # adjust_for_ambient_noise() will override this, but it acts as the + # starting point. Increase if noise causes false triggers; decrease if + # quiet speech is missed. + self.recognizer.energy_threshold = kwargs.get('energy_threshold', 300) + + # Allow the recognizer to adapt the threshold over time. Keep True + # unless the environment is very stable; False locks the threshold at + # the value set by adjust_for_ambient_noise. + self.recognizer.dynamic_energy_threshold = kwargs.get('dynamic_energy_threshold', True) + + # Seconds of silence that end a phrase. Default (0.8 s) cuts off speech + # too aggressively; 2 s gives more natural pauses. + self.recognizer.pause_threshold = kwargs.get('pause_threshold', 2) + + # Seconds of non-speaking audio kept at the edges of a recording. + # Increasing this avoids clipping the first/last syllable. + self.recognizer.non_speaking_duration = kwargs.get('non_speaking_duration', 0.8) + + self.device_name = kwargs.get('device_name', 'pulse') self.device = self.get_device_index(self.device_name) self.sample_rate = kwargs.get('sample_rate', 16000) self.mic = sr.Microphone(device_index=self.device, sample_rate=self.sample_rate) self.listening = False - + + # Recalibrate ambient noise after this many consecutive timeouts. + # Timeouts mean the energy threshold has drifted above the speech level. + self.recalibrate_interval = kwargs.get('recalibrate_interval', 5) + self.start_on_boot = kwargs.get('start_on_boot', False) + self.wake_word = kwargs.get('wake_word', None) + + self.capture_detections = kwargs.get('capture_detections', False) + self.repeat_captures = kwargs.get('repeat_captures', False) + print('SpeechInput initialized with device ' + str(self.device) + ' and sample rate ' + str(self.sample_rate) + '. Start on boot: ' + str(self.start_on_boot)) def setup_messaging(self): self.subscribe('speech:listen', self.start) @@ -34,6 +63,7 @@ def __del__(self): def start(self): self.listening = True + self.log('Starting speech detection') Thread(target=self.detect, args=()).start() return self @@ -43,6 +73,8 @@ def get_device_index(self, device_name): if name == device_name: print('Mapping mic to index ' + str(index)) return index + # throw exception if device name is not found + raise Exception('Device name ' + device_name + ' not found. Available devices: ' + str(sr.Microphone.list_microphone_names())) def detect(self): """ @@ -52,25 +84,50 @@ def detect(self): self.log('Initialising Detection') with self.mic as source: self.recognizer.adjust_for_ambient_noise(source, duration=2) - # self.recognizer.energy_threshold = 300 # Adjust based on your environment - self.log('Detecting...') + self.log('Detecting... (energy threshold: ' + str(int(self.recognizer.energy_threshold)) + ')') + timeout_count = 0 while self.listening: try: audio = self.recognizer.listen(source, timeout=10, phrase_time_limit=15) + timeout_count = 0 val = self.recognizer.recognize_google(audio) - #save audio with filename as val substituting any non alphanumeric characters with underscores - filename = str(val).replace(' ', '_').replace('[^a-zA-Z0-9]', '_') - with open("speech_" + filename + ".wav", "wb") as f: - f.write(audio.get_wav_data()) - + # if wake word is set check each word in list against wake word and ignore if not detected + if self.wake_word: + found = False + for word in self.wake_word: + if word.lower() in val.lower(): + found = True + break + if not found: + self.log('Wake word "' + str(self.wake_word) + '" not detected in "' + val + '". Ignoring.') + continue + + if self.capture_detections: + #save audio with filename as val substituting any non alphanumeric characters with underscores + filename = str(val).replace(' ', '_').replace('[^a-zA-Z0-9]', '_') + with open("speech_" + filename + ".wav", "wb") as f: + f.write(audio.get_wav_data()) + if self.repeat_captures: + # play back file via speakers + self.log('Playing back audio') + subprocess.call(['aplay', '-D', 'plughw:cm5i2saudio,1', "speech_" + filename + ".wav"]) + self.log('I heard: ' + str(val)) self.publish('speech', text=val.lower()) self.publish('tts', msg='I heard ' + val.lower()) - except sr.WaitTimeoutError as e: - self.publish('log/error', message='[Speech] Timeout Error: ' + str(e)) - except sr.UnknownValueError as e: + self.publish('rsvp/text', text='I heard: ' + val.lower()) + except sr.WaitTimeoutError: + timeout_count += 1 + if timeout_count >= self.recalibrate_interval: + self.log('Recalibrating for ambient noise after ' + str(timeout_count) + ' timeouts...') + self.recognizer.adjust_for_ambient_noise(source, duration=1) + self.log('Energy threshold now: ' + str(int(self.recognizer.energy_threshold))) + timeout_count = 0 + except sr.UnknownValueError: pass + except Exception as e: + self.log('Detection error: ' + str(e)) # self.publish('log/error', msg='[Speech] Detection Error: ' + str(e)) # finally: # self.publish('led', identifiers='top5', color='off') diff --git a/src/modules/audio/speechinput/tests/test_speechinput.py b/src/modules/audio/speechinput/tests/test_speechinput.py index 5e6f418c..2ded7999 100644 --- a/src/modules/audio/speechinput/tests/test_speechinput.py +++ b/src/modules/audio/speechinput/tests/test_speechinput.py @@ -7,6 +7,7 @@ mock_microphone = MagicMock() mock_speech_recognition.Recognizer.return_value = mock_recognizer mock_speech_recognition.Microphone.return_value = mock_microphone +mock_speech_recognition.Microphone.list_microphone_names.return_value = ['test', 'pulse'] sys.modules['speech_recognition'] = mock_speech_recognition from modules.audio.speechinput.speechinput import SpeechInput diff --git a/src/modules/audio/ttsmodule/config.yml b/src/modules/audio/ttsmodule/config.yml index 36be8926..02a8c6b3 100644 --- a/src/modules/audio/ttsmodule/config.yml +++ b/src/modules/audio/ttsmodule/config.yml @@ -11,5 +11,5 @@ ttsmodule: - ffmpeg - libespeak1 additional: - - installers/install-amp.sh + - installers/install-audio.sh class: TTSModule diff --git a/src/modules/chatgpt/config.yml b/src/modules/chatgpt/config.yml index 756ef472..ff82b163 100644 --- a/src/modules/chatgpt/config.yml +++ b/src/modules/chatgpt/config.yml @@ -1,10 +1,7 @@ chatgpt: config: model: gpt-4o-mini - persona: 'You are a helpful assistant robot. You respond with short phrases where - possible. Alternatively, you can respond with the following commands instead - of text if you feel they are appropriate: - animate/head_nod - animate/head_shake - - gpio/laser/on - gpio/laser/off' + persona: 'You are a helpful assistant robot. You respond with short phrases where possible.' dependencies: python: - openai diff --git a/src/modules/display/rsvp_display/__init__.py b/src/modules/display/rsvp_display/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/display/rsvp_display/config.yml b/src/modules/display/rsvp_display/config.yml new file mode 100644 index 00000000..ce5dc6d4 --- /dev/null +++ b/src/modules/display/rsvp_display/config.yml @@ -0,0 +1,15 @@ +rsvp_display: + class: RSVPDisplay + config: + wpm: 250 + font_size: 40 + text_color: [255, 255, 255] + highlight_color: [255, 50, 50] + dependencies: + unix: + - python3-pip + - python3-pil + - python3-numpy + - fonts-dejavu-core + python: + - spidev diff --git a/src/modules/display/rsvp_display/rsvp_display.py b/src/modules/display/rsvp_display/rsvp_display.py new file mode 100644 index 00000000..e25a00f7 --- /dev/null +++ b/src/modules/display/rsvp_display/rsvp_display.py @@ -0,0 +1,178 @@ +import threading +import time +from PIL import Image, ImageDraw, ImageFont +from modules.display.tft_display import TFTDisplay + + +class RSVPDisplay(TFTDisplay): + """ + Rapid Serial Visual Presentation (RSVP) display module. + + Subscribes to 'rsvp/text' and renders the received string one word at a time + on the SPI TFT display. Each word is shown with the Optimal Recognition Point + (ORP) character highlighted in a configurable colour and aligned to a fixed + horizontal position so the reader's eye never has to move. + + Configuration kwargs + -------------------- + wpm : int + Words per minute rate (default 250). + font_size : int + Font size in pixels (default 40). + text_color : tuple + RGB colour for normal characters, e.g. (255, 255, 255) for white. + highlight_color : tuple + RGB colour for the ORP character, e.g. (255, 50, 50) for red. + """ + + PUNCTUATION = frozenset('.!?,;:') + + def __init__(self, **kwargs): + super().__init__(**kwargs) + print('Initializing RSVPDisplay with config: ' + str(kwargs)) + self.test_on_boot = kwargs.get('test_on_boot', False) + self.wpm = kwargs.get('wpm', 250) + self.font_size = kwargs.get('font_size', 40) + self.text_color = tuple(kwargs.get('text_color', (255, 255, 255))) + self.highlight_color = tuple(kwargs.get('highlight_color', (255, 50, 50))) + self._stop_event = threading.Event() + self._rsvp_thread = None + try: + self._font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', self.font_size) + except (IOError, OSError): + self._font = ImageFont.load_default() + + def setup_messaging(self): + super().setup_messaging() + self.subscribe('rsvp/text', self.display_rsvp) + + def on_load(self): + if self.test_on_boot: + self.display_rsvp('This is an RSVP Display. It shows one word at a time with the optimal recognition point highlighted.') + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def display_rsvp(self, text): + """Start displaying *text* in RSVP format. Any ongoing sequence is stopped first.""" + self.log('Received text for RSVP display: ' + str(text)) + self._stop_rsvp() + self._stop_event.clear() + self._rsvp_thread = threading.Thread( + target=self._rsvp_loop, + args=(text,), + daemon=True, + ) + self._rsvp_thread.start() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _stop_rsvp(self): + """Signal the current RSVP thread to stop and wait for it.""" + self._stop_event.set() + if self._rsvp_thread and self._rsvp_thread.is_alive(): + self._rsvp_thread.join() + + def _rsvp_loop(self, text): + words = text.split() + orp_x = self._compute_sentence_orp_x(words) + for word in words: + if self._stop_event.is_set(): + break + self._render_word(word, orp_x) + delay = self._word_delay(word) + self._stop_event.wait(timeout=delay) + + def _compute_sentence_orp_x(self, words): + """Return a fixed ORP x position for all words in the sentence. + + Each word is measured as if it were centred on the display, and the + resulting ORP left-edge positions are averaged so that the highlighted + character stays roughly stationary while words appear centred. + """ + if not words: + return self.background.width // 2 + draw = ImageDraw.Draw(self.background) + width = self.background.width + positions = [] + for word in words: + if not word: + continue + orp_idx = self._get_orp_index(word) + left_w = self._text_width(draw, word[:orp_idx]) + orp_w = self._text_width(draw, word[orp_idx]) + right_w = self._text_width(draw, word[orp_idx + 1:]) + full_w = left_w + orp_w + right_w + word_left_x = width // 2 - full_w // 2 + positions.append(word_left_x + left_w) + return int(sum(positions) / len(positions)) + + def _get_orp_index(self, word): + """Return the index of the Optimal Recognition Point character.""" + length = len(word) + if length <= 1: + return 0 + # ORP sits at ~30 % into the word (0-based, clamped to valid range) + return max(0, min(length - 1, int(length * 0.3))) + + def _word_delay(self, word): + """Return display duration (seconds) for *word* at the configured WPM.""" + base = 60.0 / self.wpm + if word and word[-1] in self.PUNCTUATION: + return base * 2.5 + return base + + def _render_word(self, word, orp_x=None): + """Draw *word* on a black background with the ORP character highlighted.""" + if not word: + return + + orp_idx = self._get_orp_index(word) + left_part = word[:orp_idx] + orp_char = word[orp_idx] + right_part = word[orp_idx + 1:] + + img = self.background.copy() + draw = ImageDraw.Draw(img) + + width = img.width + height = img.height + y_center = height // 2 + + # Measure individual segment widths + left_w = self._text_width(draw, left_part) + orp_w = self._text_width(draw, orp_char) + + # Use the pre-computed sentence ORP position, or fall back to centre + if orp_x is None: + orp_x = width // 2 - orp_w // 2 + left_x = orp_x - left_w + right_x = orp_x + orp_w + + # Vertical position: centre the text + text_h = self._text_height(draw, orp_char) + y = y_center - text_h // 2 + + if left_part: + draw.text((left_x, y), left_part, font=self._font, fill=self.text_color) + draw.text((orp_x, y), orp_char, font=self._font, fill=self.highlight_color) + if right_part: + draw.text((right_x, y), right_part, font=self._font, fill=self.text_color) + + self.show_image(img.rotate(self.rotation)) + + def _text_width(self, draw, text): + """Return the pixel width of *text* using the current font.""" + if not text: + return 0 + bbox = draw.textbbox((0, 0), text, font=self._font) + return bbox[2] - bbox[0] + + def _text_height(self, draw, text): + """Return the pixel height of *text* using the current font.""" + if not text: + return 0 + bbox = draw.textbbox((0, 0), text, font=self._font) + return bbox[3] - bbox[1] diff --git a/src/modules/display/rsvp_display/tests/__init__.py b/src/modules/display/rsvp_display/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/display/rsvp_display/tests/test_rsvp_display.py b/src/modules/display/rsvp_display/tests/test_rsvp_display.py new file mode 100644 index 00000000..5e4b7d4f --- /dev/null +++ b/src/modules/display/rsvp_display/tests/test_rsvp_display.py @@ -0,0 +1,367 @@ +import sys +import time +import threading +import unittest +from unittest.mock import MagicMock, patch, call + +# --------------------------------------------------------------------------- +# Stub out all hardware / third-party modules before importing the module +# --------------------------------------------------------------------------- +mock_spi = MagicMock() +mock_pil = MagicMock() +mock_image_cls = MagicMock() +mock_image_draw_cls = MagicMock() +mock_image_font_cls = MagicMock() +mock_lcd = MagicMock() +mock_lcd_module = MagicMock() + +sys.modules['spidev'] = mock_spi +sys.modules['PIL'] = mock_pil +sys.modules['PIL.Image'] = mock_image_cls +sys.modules['PIL.ImageDraw'] = mock_image_draw_cls +sys.modules['PIL.ImageFont'] = mock_image_font_cls +sys.modules['modules.display.lib'] = mock_lcd +sys.modules['modules.display.lib.LCD_1inch28'] = mock_lcd_module + +# Stub out TFTDisplay so we don't need real hardware +mock_tft_display_mod = MagicMock() +sys.modules['modules.display.tft_display'] = mock_tft_display_mod + + +class _FakeTFTDisplay: + """Minimal stand-in for TFTDisplay used in tests.""" + + def __init__(self, **kwargs): + disp = MagicMock() + disp.width = 240 + disp.height = 240 + self.disp = disp + self.background = MagicMock() + self.background.copy.return_value = MagicMock() + + def setup_messaging(self): + pass + + def subscribe(self, topic, callback, **kwargs): + pass + + def publish(self, topic, *args, **kwargs): + pass + + def show_image(self, image): + pass + + +mock_tft_display_mod.TFTDisplay = _FakeTFTDisplay + +# Now import the module under test +from modules.display.rsvp_display.rsvp_display import RSVPDisplay # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_rsvp(**kwargs): + """Return an RSVPDisplay instance with mocked low-level dependencies.""" + with patch('modules.display.rsvp_display.rsvp_display.ImageFont') as mock_font_mod: + mock_font_mod.truetype.return_value = MagicMock() + inst = RSVPDisplay(**kwargs) + inst.subscribe = MagicMock() + inst.show_image = MagicMock() + inst._font = MagicMock() + return inst + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestRSVPDisplayInit(unittest.TestCase): + + def test_default_wpm(self): + rsvp = _make_rsvp() + self.assertEqual(rsvp.wpm, 250) + + def test_custom_wpm(self): + rsvp = _make_rsvp(wpm=300) + self.assertEqual(rsvp.wpm, 300) + + def test_default_font_size(self): + rsvp = _make_rsvp() + self.assertEqual(rsvp.font_size, 40) + + def test_custom_font_size(self): + rsvp = _make_rsvp(font_size=32) + self.assertEqual(rsvp.font_size, 32) + + def test_default_text_color(self): + rsvp = _make_rsvp() + self.assertEqual(rsvp.text_color, (255, 255, 255)) + + def test_custom_text_color(self): + rsvp = _make_rsvp(text_color=(200, 200, 200)) + self.assertEqual(rsvp.text_color, (200, 200, 200)) + + def test_default_highlight_color(self): + rsvp = _make_rsvp() + self.assertEqual(rsvp.highlight_color, (255, 50, 50)) + + def test_custom_highlight_color(self): + rsvp = _make_rsvp(highlight_color=(0, 255, 0)) + self.assertEqual(rsvp.highlight_color, (0, 255, 0)) + + def test_font_fallback_to_default(self): + """When truetype font is unavailable, load_default() is used.""" + with patch('modules.display.rsvp_display.rsvp_display.ImageFont') as mock_font_mod: + mock_font_mod.truetype.side_effect = IOError + mock_font_mod.load_default.return_value = MagicMock() + rsvp = RSVPDisplay() + mock_font_mod.load_default.assert_called_once() + + def test_stop_event_initialised(self): + rsvp = _make_rsvp() + self.assertIsInstance(rsvp._stop_event, threading.Event) + + def test_rsvp_thread_initially_none(self): + rsvp = _make_rsvp() + self.assertIsNone(rsvp._rsvp_thread) + + +class TestSetupMessaging(unittest.TestCase): + + def test_subscribes_to_rsvp_text(self): + rsvp = _make_rsvp() + rsvp.subscribe = MagicMock() + rsvp.setup_messaging() + rsvp.subscribe.assert_called_with('rsvp/text', rsvp.display_rsvp) + + +class TestGetOrpIndex(unittest.TestCase): + + def test_single_char(self): + rsvp = _make_rsvp() + self.assertEqual(rsvp._get_orp_index('a'), 0) + + def test_two_chars(self): + rsvp = _make_rsvp() + # int(2 * 0.3) = 0, max(0, 0) = 0 + self.assertEqual(rsvp._get_orp_index('ab'), 0) + + def test_five_chars(self): + rsvp = _make_rsvp() + # int(5 * 0.3) = 1 + self.assertEqual(rsvp._get_orp_index('hello'), 1) + + def test_ten_chars(self): + rsvp = _make_rsvp() + # int(10 * 0.3) = 3 + self.assertEqual(rsvp._get_orp_index('abcdefghij'), 3) + + def test_orp_never_exceeds_last_index(self): + rsvp = _make_rsvp() + word = 'x' + idx = rsvp._get_orp_index(word) + self.assertLess(idx, len(word)) + + +class TestWordDelay(unittest.TestCase): + + def test_base_delay_250wpm(self): + rsvp = _make_rsvp(wpm=250) + self.assertAlmostEqual(rsvp._word_delay('hello'), 60.0 / 250) + + def test_punctuation_doubles_delay(self): + rsvp = _make_rsvp(wpm=250) + base = 60.0 / 250 + self.assertAlmostEqual(rsvp._word_delay('hello.'), base * 2.5) + + def test_all_punctuation_triggers_pause(self): + rsvp = _make_rsvp(wpm=250) + base = 60.0 / 250 + for ch in '.!?,;:': + with self.subTest(ch=ch): + self.assertAlmostEqual(rsvp._word_delay('word' + ch), base * 2.5) + + def test_empty_word_returns_base_delay(self): + rsvp = _make_rsvp(wpm=250) + self.assertAlmostEqual(rsvp._word_delay(''), 60.0 / 250) + + +class TestTextMeasurement(unittest.TestCase): + + def _draw_with_bbox(self, bbox): + draw = MagicMock() + draw.textbbox.return_value = bbox + return draw + + def test_text_width_empty_string(self): + rsvp = _make_rsvp() + draw = MagicMock() + self.assertEqual(rsvp._text_width(draw, ''), 0) + + def test_text_width_non_empty(self): + rsvp = _make_rsvp() + draw = self._draw_with_bbox((0, 0, 50, 20)) + self.assertEqual(rsvp._text_width(draw, 'hi'), 50) + + def test_text_height_empty_string(self): + rsvp = _make_rsvp() + draw = MagicMock() + self.assertEqual(rsvp._text_height(draw, ''), 0) + + def test_text_height_non_empty(self): + rsvp = _make_rsvp() + draw = self._draw_with_bbox((0, 5, 50, 25)) + self.assertEqual(rsvp._text_height(draw, 'hi'), 20) + + +class TestRenderWord(unittest.TestCase): + + def _make_rsvp_with_draw(self): + rsvp = _make_rsvp() + # Provide a concrete background with copy() returning a real-ish mock + mock_img = MagicMock() + mock_img.width = 240 + mock_img.height = 240 + rsvp.background = MagicMock() + rsvp.background.copy.return_value = mock_img + return rsvp + + def test_empty_word_does_nothing(self): + rsvp = self._make_rsvp_with_draw() + rsvp.show_image = MagicMock() + rsvp._render_word('') + rsvp.show_image.assert_not_called() + + def test_show_image_called_for_non_empty_word(self): + rsvp = self._make_rsvp_with_draw() + with patch('modules.display.rsvp_display.rsvp_display.ImageDraw') as mock_id: + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 20, 30) + mock_id.Draw.return_value = mock_draw + rsvp._render_word('hello') + rsvp.show_image.assert_called_once() + + def test_orp_char_drawn_with_highlight_color(self): + rsvp = self._make_rsvp_with_draw() + with patch('modules.display.rsvp_display.rsvp_display.ImageDraw') as mock_id: + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 20, 30) + mock_id.Draw.return_value = mock_draw + rsvp._render_word('hello') + # Check that draw.text was called with the highlight colour + calls = mock_draw.text.call_args_list + highlight_calls = [c for c in calls if c.kwargs.get('fill') == rsvp.highlight_color + or (len(c.args) >= 3 and c.args[2] == rsvp.highlight_color)] + # At least one call should use the highlight colour + self.assertTrue(len(highlight_calls) >= 1 or + any(rsvp.highlight_color in str(c) for c in calls)) + + def test_single_char_word(self): + rsvp = self._make_rsvp_with_draw() + with patch('modules.display.rsvp_display.rsvp_display.ImageDraw') as mock_id: + mock_draw = MagicMock() + mock_draw.textbbox.return_value = (0, 0, 20, 30) + mock_id.Draw.return_value = mock_draw + # Should not raise + rsvp._render_word('a') + rsvp.show_image.assert_called_once() + + +class TestDisplayRsvp(unittest.TestCase): + + def test_starts_thread(self): + rsvp = _make_rsvp() + with patch.object(rsvp, '_render_word'), \ + patch.object(rsvp._stop_event, 'wait', return_value=True): + rsvp.display_rsvp('hello world') + time.sleep(0.05) + self.assertIsNotNone(rsvp._rsvp_thread) + + def test_stops_previous_thread_before_starting_new(self): + rsvp = _make_rsvp() + with patch.object(rsvp, '_stop_rsvp') as mock_stop, \ + patch.object(rsvp, '_render_word'), \ + patch.object(rsvp._stop_event, 'wait', return_value=True): + rsvp.display_rsvp('one two') + mock_stop.assert_called_once() + + def test_empty_text_starts_thread(self): + rsvp = _make_rsvp() + with patch.object(rsvp, '_render_word') as mock_render, \ + patch.object(rsvp._stop_event, 'wait', return_value=True): + rsvp.display_rsvp('') + time.sleep(0.05) + mock_render.assert_not_called() + + def test_stop_event_cleared_before_loop(self): + rsvp = _make_rsvp() + rsvp._stop_event.set() # pre-set + with patch.object(rsvp, '_render_word'), \ + patch.object(rsvp._stop_event, 'wait', return_value=True): + rsvp.display_rsvp('test') + time.sleep(0.05) + # Event should have been cleared, allowing words to display + # (thread may have already finished; just verify no exception) + + +class TestRSVPLoop(unittest.TestCase): + + def test_renders_each_word(self): + rsvp = _make_rsvp() + render_calls = [] + rsvp._render_word = lambda w: render_calls.append(w) + rsvp._stop_event.clear() + # speed up: make wait return True immediately + rsvp._stop_event.wait = MagicMock(return_value=False) + rsvp._rsvp_loop('one two three') + self.assertEqual(render_calls, ['one', 'two', 'three']) + + def test_stops_when_stop_event_set(self): + rsvp = _make_rsvp() + render_calls = [] + + def fake_render(word): + render_calls.append(word) + rsvp._stop_event.set() # stop after first word + + rsvp._render_word = fake_render + rsvp._stop_event.clear() + rsvp._stop_event.wait = MagicMock(return_value=True) + rsvp._rsvp_loop('one two three') + self.assertEqual(render_calls, ['one']) + + def test_empty_text_renders_nothing(self): + rsvp = _make_rsvp() + render_calls = [] + rsvp._render_word = lambda w: render_calls.append(w) + rsvp._stop_event.clear() + rsvp._rsvp_loop('') + self.assertEqual(render_calls, []) + + +class TestStopRsvp(unittest.TestCase): + + def test_sets_stop_event(self): + rsvp = _make_rsvp() + rsvp._stop_rsvp() + self.assertTrue(rsvp._stop_event.is_set()) + + def test_joins_running_thread(self): + rsvp = _make_rsvp() + mock_thread = MagicMock() + mock_thread.is_alive.return_value = True + rsvp._rsvp_thread = mock_thread + rsvp._stop_rsvp() + mock_thread.join.assert_called_once() + + def test_no_error_when_thread_is_none(self): + rsvp = _make_rsvp() + rsvp._rsvp_thread = None + # Should not raise + rsvp._stop_rsvp() + + +if __name__ == '__main__': + unittest.main() diff --git a/src/modules/display/tft_display.py b/src/modules/display/tft_display.py index 1ccbde76..104106df 100644 --- a/src/modules/display/tft_display.py +++ b/src/modules/display/tft_display.py @@ -13,11 +13,11 @@ class TFTDisplay(BaseModule): def __init__(self, **kwargs): self.rotation = kwargs.get('rotation', 0) try: - spi = SPI.SpiDev(kwargs.get('bus'), kwargs.get('device')) + spi = SPI.SpiDev(kwargs.get('bus', 0), kwargs.get('device', 0)) spi.max_speed_hz = 10000000 self.disp = LCD_1inch28.LCD_1inch28( - spi=spi, spi_freq=10000000, rst=kwargs.get('rst_pin'), - dc=kwargs.get('dc_pin'), bl=kwargs.get('bl_pin') + spi=spi, spi_freq=10000000, rst=kwargs.get('rst_pin', 27), + dc=kwargs.get('dc_pin', 25), bl=kwargs.get('bl_pin', 18) ) self.img = None self.clear_display() diff --git a/src/modules/display/tft_display_eye/config.yml b/src/modules/display/tft_display_eye/config.yml index 34aec457..78ba18ed 100644 --- a/src/modules/display/tft_display_eye/config.yml +++ b/src/modules/display/tft_display_eye/config.yml @@ -1,4 +1,4 @@ -tft_display: +tft_display_eye: class: TFTDisplayEye config: colors: