Technical details
The Lyre of Thirst works through a combination of physical construction, flowing water, and interactive sensing. We designed the lyre body and the water reservoir box using the CNC machine, which allowed us to cut the thick wood shapes accurately. Inside the wooden box, we placed the water pump connected to a speed controller and a 12-volt Talentcell battery that powered the entire system. A pipe carried the water from the reservoir up to the top of the lyre. Small holes in the pipe released water, which fell directly onto the fishing line strings; the water then slid down the strings and collected in the lower part of the structure in an acrylic bowl. From there, it flowed back into the larger reservoir through a dedicated opening. This closed system allowed the lyre to run for hours without losing a significant amount of water and without mechanical issues. To enrich the visual environment, we also added 3D printed flowers and leaves. We selected designs available online and printed them to represent the sparse but meaningful vegetation that survives in the desert.
To coordinate all the electronics, we used two CircuitPython microcontrollers. One managed the laser sensor, one LED strip, and a speaker for the interactive audio. The second controlled the continuous background audio and the second LED strip.
Interaction & Sensors
The interactive part of the project came from the three strings, which each triggered a different audio story. When users gently touched a string, a laser sensor placed horizontally on one side of the lyre detected a change in the light signal. The sensor then sent information to the system, which played the audio linked to that specific string. The first story explained how water is stored underground in the desert; the second story described the long journeys of Bedouins as they searched for water; and lastly, the third story evoked King David playing the lyre in the desert, expressing his desire for spirituality in an arid environment. These stories played on top of a continuous ambient soundtrack that filled the space with atmosphere.
To enrich the experience, we also added two LED strips. They illuminated the flowing water and the body of the lyre, making the movement of the drops more visible and giving the installation a calm and poetic presence during the night. Together, the water flow, sensors, sound, and light created a unified interactive system where users could learn through touch and through the symbolic relationship between water and survival in desert life.
Source code for Microcontroller 1
import os
import time
import board
import neopixel
from digitalio import DigitalInOut, Direction
import adafruit_vl53l4cd
import audiobusio
import audiomixer
from audiomp3 import MP3Decoder
COOLDOWN_SEC = 0.8
# LED strip brightness
PIXEL_BRIGHTNESS = 0.30
# distance zones for three different stories
# Zone 1: closest to the sensor
ZONE1_MAX_CM = 14
# Zone 2: medium distance
ZONE2_MIN_CM = 14
ZONE2_MAX_CM = 26
# Zone 3: farthest
ZONE3_MIN_CM = 26
ZONE3_MAX_CM = 33
# paths to the three MP3 files
MP3_ZONE1 = "/songs/Story1.mp3"
MP3_ZONE2 = "/songs/Story2.mp3"
MP3_ZONE3 = "/songs/Story3.mp3"
# audio mixer settings
MIXER_SAMPLE_RATE = 22050
# mono audio
MIXER_CHANNELS = 1
# volume of the story audio
SFX_LEVEL = 1.0
external_power = DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = Direction.OUTPUT
external_power.value = True
print("External power enabled.")
# lED strip configuration
NUM_PIXELS = 17
PIXEL_PIN = board.D6
pixels = neopixel.NeoPixel(
PIXEL_PIN,
NUM_PIXELS,
brightness=PIXEL_BRIGHTNESS,
auto_write=False,
pixel_order=neopixel.GRB,
)
#constant deeper blue color for all pixels
pixels.fill((31, 69, 115))
pixels.show()
# VL53L4CD distance sensor setup
i2c = board.STEMMA_I2C()
vl53 = adafruit_vl53l4cd.VL53L4CD(i2c)
vl53.inter_measurement = 0
vl53.timing_budget = 200
vl53.start_ranging()
print("VL53L4CD started ranging.")
# audio output setup
audio = audiobusio.I2SOut(
board.I2S_BIT_CLOCK,
board.I2S_WORD_SELECT,
board.I2S_DATA
)
# only 1 voice used here for the current story sound
mixer = audiomixer.Mixer(
voice_count=1,
sample_rate=MIXER_SAMPLE_RATE,
channel_count=MIXER_CHANNELS,
bits_per_sample=16,
samples_signed=True,
)
audio.play(mixer)
# globals to track current MP3 playback
sfx_decoder = None
sfx_file = None
sfx_playing = False
current_track = None
def mp3_exists(path):
#return True if an MP3 file exists at the given path, False otherwise
try:
os.stat(path)
return True
except OSError:
return False
def start_sfx(path, track_label):
# start playing an MP3 from 'path' as the active story. if something is already playing, stop it first. track_label: a short string like "z1", "z2", "z3" used so we know which region is playing.
global sfx_decoder, sfx_file, sfx_playing, current_track
# always stop any previous playback before starting a new one
stop_sfx()
# check that the file actually exists
if not mp3_exists(path):
print("SFX MP3 not found at", path)
return
try:
# first time: create the decoder
if sfx_decoder is None:
sfx_file = open(path, "rb")
sfx_decoder = MP3Decoder(sfx_file)
else:
# reuse the decoder by closing the old file and opening a new one
if sfx_file:
try:
sfx_file.close()
except Exception:
pass
sfx_file = open(path, "rb")
sfx_decoder.file = sfx_file
# configure mixer voice and start playback
mixer.voice[0].level = SFX_LEVEL
mixer.voice[0].play(sfx_decoder, loop=False)
sfx_playing = True
current_track = track_label
print("Playing SFX:", path, "| track:", track_label)
except Exception as e:
# if something goes wrong, log it and reset the state
print("ERROR starting SFX MP3:", e)
sfx_playing = False
current_track = None
def stop_sfx():
# stop any currently playing story, stop the mixer voice, and close the file
global sfx_decoder, sfx_file, sfx_playing, current_track
# safely stop the mixer voice if it is playing
try:
if mixer.voice[0].playing:
mixer.voice[0].stop()
except Exception:
pass
# close the currently open file (if any)
if sfx_file:
try:
sfx_file.close()
except Exception:
pass
sfx_file = None
sfx_playing = False
current_track = None
last_trigger_time = 0.0
PRINT_EVERY = 0.1
last_print = 0.0
last_region = "far"
# debug info for the zones
print("Zones configured:")
print(" ZONE1: d <", ZONE1_MAX_CM, "cm ->", MP3_ZONE1)
print(" ZONE2:", ZONE2_MIN_CM, "<= d <", ZONE2_MAX_CM, "cm ->", MP3_ZONE2)
print(" ZONE3:", ZONE3_MIN_CM, "<= d <=", ZONE3_MAX_CM, "cm ->", MP3_ZONE3)
while True:
# wait until sensor has a fresh measurement ready
while not vl53.data_ready:
time.sleep(0.002)
# clear sensor interrupt and read distance in cm
vl53.clear_interrupt()
d = vl53.distance
now = time.monotonic()
# periodic debug print of distance and region
if (now - last_print) >= PRINT_EVERY:
print("Distance:", d, "cm", "| region:", last_region, "| sfx_playing:", sfx_playing)
last_print = now
# when the user removes their hand, the sensor sees an obstruction at ~35–37 cm. if a story is already playing and the distance is in this hold range, we "pretend" that we are still in the previous region so the story continues and no new toggles happen
if sfx_playing and 34 <= d <= 39:
region = last_region
else:
# normal region detection based on current distance
if d < ZONE1_MAX_CM:
region = "z1"
elif ZONE2_MIN_CM <= d < ZONE2_MAX_CM:
region = "z2"
elif ZONE3_MIN_CM <= d <= ZONE3_MAX_CM:
region = "z3"
else:
region = "far"
# only react when the detected region changes and at least COOLDOWN_SEC seconds have passed
if region != last_region and (now - last_trigger_time) >= COOLDOWN_SEC:
last_trigger_time = now
if region == "z1":
# if we re-touch the same zone, stop that track
if sfx_playing and current_track == "z1":
stop_sfx()
else:
# otherwise start Zone 1 story
start_sfx(MP3_ZONE1, "z1")
elif region == "z2":
if sfx_playing and current_track == "z2":
stop_sfx()
else:
start_sfx(MP3_ZONE2, "z2")
elif region == "z3":
if sfx_playing and current_track == "z3":
stop_sfx()
else:
start_sfx(MP3_ZONE3, "z3")
# remember region for next iteration
last_region = region
# auto-stop when the track finishes playing
if sfx_playing and not mixer.voice[0].playing:
stop_sfx()
# small delay to avoid hammering the CPU
time.sleep(0.01)
Source code for Microcontroller 2
import time
import os
import board
import neopixel
from digitalio import DigitalInOut, Direction
import audiobusio
from audiomp3 import MP3Decoder
# file name of looping ambient soundtrack
MP3_BG_PATH = "BackgroundMusic.mp3"
PIXEL_BRIGHTNESS = 0.30
NUM_PIXELS = 12
PIXEL_PIN = board.D6
# deep blue color
SOLID_COLOR = (31, 69, 115, 0)
external_power = DigitalInOut(board.EXTERNAL_POWER)
external_power.direction = Direction.OUTPUT
external_power.value = True
print("External power enabled (background board).")
# configuring the led strip
pixels = neopixel.NeoPixel(
PIXEL_PIN,
NUM_PIXELS,
brightness=PIXEL_BRIGHTNESS,
auto_write=False,
pixel_order=neopixel.GRBW,
bpp=4,
)
pixels.fill(SOLID_COLOR)
pixels.show()
# configure the I2S output pins for the MP3 background audio
audio = audiobusio.I2SOut(
board.I2S_BIT_CLOCK,
board.I2S_WORD_SELECT,
board.I2S_DATA,
)
# these hold the open MP3 file and its decoder
bg_file = None
bg_decoder = None
def mp3_exists(path):
#return True if the MP3 file exists on the device storage
try:
os.stat(path)
return True
except OSError:
return False
def start_background():
# open and start looping the background MP3 track. if the track is already playing, it does nothing. recovers automatically if the file or decoder breaks.
global bg_file, bg_decoder
# check file
if not mp3_exists(MP3_BG_PATH):
print("Background MP3 not found at", MP3_BG_PATH)
return
# already playing → do not restart
if bg_decoder is not None and audio.playing:
return
# safely close previous file (if any)
if bg_file:
try:
bg_file.close()
except Exception:
pass
bg_file = None
# attempt to open and play the MP3
try:
bg_file = open(MP3_BG_PATH, "rb")
bg_decoder = MP3Decoder(bg_file)
audio.play(bg_decoder, loop=True)
print("Background music started:", MP3_BG_PATH)
except Exception as e:
print("ERROR starting background MP3:", e)
# clean up on failure
try:
if bg_file:
bg_file.close()
except Exception:
pass
bg_file = None
bg_decoder = None
print("Background board ready.")
print(" Background MP3:", MP3_BG_PATH)
# start playback once on boot
start_background()
last_print = 0.0
PRINT_EVERY = 5.0
while True:
now = time.monotonic()
# if audio unexpectedly stops (decoder stalls / buffer fails), restart the looping background soundtrack automatically.
if bg_decoder is not None and not audio.playing:
print("Background stopped unexpectedly, restarting...")
start_background()
# LEDs remain static red — no animation required here.
# periodic status message
if (now - last_print) >= PRINT_EVERY:
print("Background board running | playing:", audio.playing)
last_print = now
time.sleep(0.01)
Our 2D and 3D designs
Lyre Design:

The black lines represent the engraving made for the water pipes.
Box Design:



Base Lid With 50 cm x 50 cm take dimension

The Top lid hole of 12.5 cm in diameter, for the water falling into the reservoir.


Pictures




