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.

Downloaded flowers link:

Leaves link:

Pictures

Categories: Fall 2025