ayal
Published © GPL3+

Echo V1

Wearable Training Device for Echolocation

IntermediateShowcase (no instructions)35
Echo V1

Things used in this project

Hardware components

Raspberry Pi Pico W
Raspberry Pi Pico W
×1
Ultrasonic Sensor - HC-SR04 (Generic)
Ultrasonic Sensor - HC-SR04 (Generic)
×3
Fermion: MEMS Microphone Module
DFRobot Fermion: MEMS Microphone Module
×1
generic portable applicance charger
×1
medical face shield
×1
USB-A to Micro-USB Cable
USB-A to Micro-USB Cable
×1

Software apps and online services

thonny python IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Code

(1) testing the mic.py

Python
import machine
import utime

# I2S pins
SCK_PIN = 18  # BCLK - pin 24
WS_PIN = 19   # LRCL - pin 25
SD_PIN = 20   # DOUT - pin 26

# setup I2S
audio_in = machine.I2S(
    0,
    sck=machine.Pin(SCK_PIN),
    ws=machine.Pin(WS_PIN),
    sd=machine.Pin(SD_PIN),
    mode=machine.I2S.RX,
    bits=16,
    format=machine.I2S.MONO,
    rate=16000,
    ibuf=4096
)

# buffer to store samples
buf = bytearray(1024)

print("Mic test running... make some noise!")

while True:
    num_read = audio_in.readinto(buf)
    # find peak value in buffer
    max_val = 0
    for i in range(0, num_read, 2):
        sample = buf[i] | (buf[i+1] << 8)
        if sample > 32767:
            sample -= 65536
        sample = abs(sample)
        if sample > max_val:
            max_val = sample
    print("Peak amplitude:", max_val)
    utime.sleep_ms(100)

(2) testing left sensor.py

Python
tests the left sensor before testing all 3
import machine
import utime

TRIG = machine.Pin(3, machine.Pin.OUT)
ECHO = machine.Pin(2, machine.Pin.IN)

def get_distance():
    TRIG.low()
    utime.sleep_us(2)
    TRIG.high()
    utime.sleep_us(10)
    TRIG.low()
    
    timeout = utime.ticks_us()
    while ECHO.value() == 0:
        if utime.ticks_diff(utime.ticks_us(), timeout) > 30000:
            return -1  # timeout
    pulse_start = utime.ticks_us()
    
    while ECHO.value() == 1:
        if utime.ticks_diff(utime.ticks_us(), pulse_start) > 30000:
            return -1  # timeout
    pulse_end = utime.ticks_us()
    
    duration = utime.ticks_diff(pulse_end, pulse_start)
    distance = (duration / 2) / 29.1
    return distance

while True:
    dist = get_distance()
    if dist == -1:
        print("No echo received - check wiring")
    else:
        print("Distance: {:.1f} cm".format(dist))
    utime.sleep_ms(500)

(3) testing all sensors.py

Python
import machine
import utime

# Left sensor
TRIG_L = machine.Pin(3, machine.Pin.OUT)
ECHO_L = machine.Pin(2, machine.Pin.IN)

# Center sensor
TRIG_C = machine.Pin(5, machine.Pin.OUT)
ECHO_C = machine.Pin(4, machine.Pin.IN)

# Right sensor
TRIG_R = machine.Pin(7, machine.Pin.OUT)
ECHO_R = machine.Pin(6, machine.Pin.IN)

def get_distance(trig, echo):
    trig.low()
    utime.sleep_us(2)
    trig.high()
    utime.sleep_us(10)
    trig.low()
    
    timeout = utime.ticks_us()
    while echo.value() == 0:
        if utime.ticks_diff(utime.ticks_us(), timeout) > 30000:
            return -1
    pulse_start = utime.ticks_us()
    
    while echo.value() == 1:
        if utime.ticks_diff(utime.ticks_us(), pulse_start) > 30000:
            return -1
    pulse_end = utime.ticks_us()
    
    duration = utime.ticks_diff(pulse_end, pulse_start)
    distance = (duration / 2) / 29.1
    return distance

while True:
    left = get_distance(TRIG_L, ECHO_L)
    center = get_distance(TRIG_C, ECHO_C)
    right = get_distance(TRIG_R, ECHO_R)
    
    print("L: {:.1f}cm  C: {:.1f}cm  R: {:.1f}cm".format(
        left if left != -1 else 0,
        center if center != -1 else 0,
        right if right != -1 else 0
    ))
    utime.sleep_ms(500)

(5) test all motors.py

Python
test all motors at once
import machine
import utime

far_left = machine.Pin(8, machine.Pin.OUT)
left = machine.Pin(10, machine.Pin.OUT)
center = machine.Pin(9, machine.Pin.OUT)
right = machine.Pin(12, machine.Pin.OUT)
far_right = machine.Pin(11, machine.Pin.OUT)

motors = [
    (far_left, "far left"),
    (left, "left"),
    (center, "center"),
    (right, "right"),
    (far_right, "far right")
]

print("Testing motors one by one...")

while True:
    for motor, name in motors:
        print("Firing: {}".format(name))
        motor.high()
        utime.sleep_ms(1000)
        motor.low()
        utime.sleep_ms(500)

(6) full code draft v2.py

Python
all the components together
import machine
import utime

# I2S mic - non-blocking mode with callback
audio_in = machine.I2S(
    0,
    sck=machine.Pin(18),
    ws=machine.Pin(19),
    sd=machine.Pin(20),
    mode=machine.I2S.RX,
    bits=16,
    format=machine.I2S.MONO,
    rate=16000,
    ibuf=4096
)
buf = bytearray(1024)

# sensors
TRIG_L = machine.Pin(3, machine.Pin.OUT)
ECHO_L = machine.Pin(2, machine.Pin.IN)
TRIG_C = machine.Pin(5, machine.Pin.OUT)
ECHO_C = machine.Pin(4, machine.Pin.IN)
TRIG_R = machine.Pin(7, machine.Pin.OUT)
ECHO_R = machine.Pin(6, machine.Pin.IN)

# motors (PWM)
motor_far_left    = machine.PWM(machine.Pin(8))
motor_center_left = machine.PWM(machine.Pin(10))
motor_center      = machine.PWM(machine.Pin(9))
motor_center_right = machine.PWM(machine.Pin(12))
motor_far_right   = machine.PWM(machine.Pin(11))

for m in [motor_far_left, motor_center_left, motor_center, motor_center_right, motor_far_right]:
    m.freq(1000)
    m.duty_u16(0)

# constants
CLICK_THRESHOLD = 2000  # mic amplitude threshold to detect a click
COOLDOWN_MS = 300       # ms to wait after a click before listening again
MAX_DISTANCE = 200      # cm - anything beyond this gives no buzzing
MIN_DISTANCE = 5        # cm - anything closer than this gives max buzzing
MAX_INTENSITY = 52000   # 80% of 65535 PWM max
ACTIVE_THRESHOLD = 200  # cm - both neighboring sensors must be within this for center left/right to fire

def get_distance(trig, echo):
    trig.low()
    utime.sleep_us(2)
    trig.high()
    utime.sleep_us(10)
    trig.low()
    
    # 50ms timeout covers up to 200cm reliably
    timeout = utime.ticks_us()
    while echo.value() == 0:
        if utime.ticks_diff(utime.ticks_us(), timeout) > 50000:
            return -1  # no echo - object absent or too far
    pulse_start = utime.ticks_us()
    
    while echo.value() == 1:
        if utime.ticks_diff(utime.ticks_us(), pulse_start) > 50000:
            return -1  # echo too long - sensor error
    pulse_end = utime.ticks_us()
    
    duration = utime.ticks_diff(pulse_end, pulse_start)
    return (duration / 2) / 29.1

def distance_to_intensity(dist):
    # -1 or beyond MAX_DISTANCE = no vibration
    if dist == -1 or dist > MAX_DISTANCE:
        return 0
    if dist < MIN_DISTANCE:
        return MAX_INTENSITY
    return int((MAX_DISTANCE - dist) / (MAX_DISTANCE - MIN_DISTANCE) * MAX_INTENSITY)

def is_active(dist):
    # sensor is considered active if it detects something within ACTIVE_THRESHOLD
    return dist != -1 and dist < ACTIVE_THRESHOLD

def all_motors_off():
    for m in [motor_far_left, motor_center_left, motor_center, motor_center_right, motor_far_right]:
        m.duty_u16(0)

click_detected = False

def i2s_callback(arg):
    # runs in background via interrupt when audio buffer is ready
    # sets click_detected flag without blocking main loop
    global click_detected
    max_val = 0
    for i in range(0, len(buf), 2):
        sample = buf[i] | (buf[i+1] << 8)
        if sample > 32767:
            sample -= 65536
        sample = abs(sample)
        if sample > max_val:
            max_val = sample
    if max_val > CLICK_THRESHOLD:
        click_detected = True
    # queue next read immediately so mic keeps listening
    audio_in.readinto(buf)

# register callback and start first read
audio_in.irq(i2s_callback)
audio_in.readinto(buf)

print("Ready - click to scan")

last_click = utime.ticks_ms()

while True:
    if click_detected:
        click_detected = False
        now = utime.ticks_ms()
        if utime.ticks_diff(now, last_click) > COOLDOWN_MS:
            last_click = now
            
            # read all 3 sensors
            dist_l = get_distance(TRIG_L, ECHO_L)
            dist_c = get_distance(TRIG_C, ECHO_C)
            dist_r = get_distance(TRIG_R, ECHO_R)
            
            print("L: {}  C: {}  R: {}".format(dist_l, dist_c, dist_r))
            
            # calculate intensities for each sensor
            int_l = distance_to_intensity(dist_l)
            int_c = distance_to_intensity(dist_c)
            int_r = distance_to_intensity(dist_r)
            
            # direct sensor to motor mapping
            motor_far_left.duty_u16(int_l)
            motor_center.duty_u16(int_c)
            motor_far_right.duty_u16(int_r)
            
            # center left fires only if both left AND center sensors are active
            if is_active(dist_l) and is_active(dist_c):
                motor_center_left.duty_u16((int_l + int_c) // 2)
            else:
                motor_center_left.duty_u16(0)
            
            # center right fires only if both right AND center sensors are active
            if is_active(dist_r) and is_active(dist_c):
                motor_center_right.duty_u16((int_r + int_c) // 2)
            else:
                motor_center_right.duty_u16(0)
            
            # motors run for 500ms then turn off
            utime.sleep_ms(500)
            all_motors_off()
    
    utime.sleep_ms(10)

(4) testing 1 motor at a time.py

Python
make sure each motor works
import machine
import utime

print("Enter GPIO number to test (8-12), or 'q' to quit")

while True:
    user_input = input("GPIO: ")
    
    if user_input == 'q':
        break
    
    try:
        pin_num = int(user_input)
        if pin_num < 8 or pin_num > 12:
            print("Please enter a number between 8 and 12")
            continue
            
        pin = machine.Pin(pin_num, machine.Pin.OUT)
        print("Firing GP{}...".format(pin_num))
        pin.high()
        utime.sleep_ms(1000)
        pin.low()
        print("Done")
        
    except ValueError:
        print("Invalid input")

(1.5) mic threshold.py

Python
read serial input to find mic threshold for vocal clicks
import machine
import utime

SCK_PIN = 18
WS_PIN = 19
SD_PIN = 20

audio_in = machine.I2S(
    0,
    sck=machine.Pin(SCK_PIN),
    ws=machine.Pin(WS_PIN),
    sd=machine.Pin(SD_PIN),
    mode=machine.I2S.RX,
    bits=16,
    format=machine.I2S.MONO,
    rate=16000,
    ibuf=4096
)

buf = bytearray(1024)

print("Listening... click near the mic and watch the values")
print("Format: ambient | peak on click")

while True:
    audio_in.readinto(buf)
    max_val = 0
    for i in range(0, len(buf), 2):
        sample = buf[i] | (buf[i+1] << 8)
        if sample > 32767:
            sample -= 65536
        sample = abs(sample)
        if sample > max_val:
            max_val = sample
    print(max_val)
    utime.sleep_ms(50)

(7) full code w: 1s cooldown.py

Python
added a 1s cooldown to remove errors of users doing multiple clicks and making motors misfire
import machine
import utime

# I2S mic - non-blocking mode with callback
audio_in = machine.I2S(
    0,
    sck=machine.Pin(18),
    ws=machine.Pin(19),
    sd=machine.Pin(20),
    mode=machine.I2S.RX,
    bits=16,
    format=machine.I2S.MONO,
    rate=16000,
    ibuf=4096
)
buf = bytearray(1024)

# sensors
TRIG_L = machine.Pin(3, machine.Pin.OUT)
ECHO_L = machine.Pin(2, machine.Pin.IN)
TRIG_C = machine.Pin(5, machine.Pin.OUT)
ECHO_C = machine.Pin(4, machine.Pin.IN)
TRIG_R = machine.Pin(7, machine.Pin.OUT)
ECHO_R = machine.Pin(6, machine.Pin.IN)

# motors (PWM)
motor_far_left    = machine.PWM(machine.Pin(8))
motor_center_left = machine.PWM(machine.Pin(10))
motor_center      = machine.PWM(machine.Pin(9))
motor_center_right = machine.PWM(machine.Pin(12))
motor_far_right   = machine.PWM(machine.Pin(11))

for m in [motor_far_left, motor_center_left, motor_center, motor_center_right, motor_far_right]:
    m.freq(1000)
    m.duty_u16(0)

# constants
CLICK_THRESHOLD = 2000  # mic amplitude threshold to detect a click
COOLDOWN_MS = 1000      # ms to wait after a FULL buzz cycle before accepting another click
MAX_DISTANCE = 200      # cm - anything beyond this gives no buzzing
MIN_DISTANCE = 5        # cm - anything closer than this gives max buzzing
MAX_INTENSITY = 52000   # 80% of 65535 PWM max
ACTIVE_THRESHOLD = 200  # cm - both neighboring sensors must be within this for center left/right to fire
BUZZ_DURATION_MS = 500  # how long motors stay on per click

def get_distance(trig, echo):
    trig.low()
    utime.sleep_us(2)
    trig.high()
    utime.sleep_us(10)
    trig.low()

    # 50ms timeout covers up to 200cm reliably
    timeout = utime.ticks_us()
    while echo.value() == 0:
        if utime.ticks_diff(utime.ticks_us(), timeout) > 50000:
            return -1  # no echo - object absent or too far
    pulse_start = utime.ticks_us()

    while echo.value() == 1:
        if utime.ticks_diff(utime.ticks_us(), pulse_start) > 50000:
            return -1  # echo too long - sensor error
    pulse_end = utime.ticks_us()

    duration = utime.ticks_diff(pulse_end, pulse_start)
    return (duration / 2) / 29.1

def distance_to_intensity(dist):
    # -1 or beyond MAX_DISTANCE = no vibration
    if dist == -1 or dist > MAX_DISTANCE:
        return 0
    if dist < MIN_DISTANCE:
        return MAX_INTENSITY
    return int((MAX_DISTANCE - dist) / (MAX_DISTANCE - MIN_DISTANCE) * MAX_INTENSITY)

def is_active(dist):
    # sensor is considered active if it detects something within ACTIVE_THRESHOLD
    return dist != -1 and dist < ACTIVE_THRESHOLD

def all_motors_off():
    for m in [motor_far_left, motor_center_left, motor_center, motor_center_right, motor_far_right]:
        m.duty_u16(0)

# ---- click detection state ----
# IMPORTANT: this flag is written inside a hardware interrupt (i2s_callback),
# which can fire at ANY time -- including while the main loop is in the
# middle of sleep_ms() running a buzz. We treat it as "armed" / "ignored"
# rather than a simple boolean, so that:
#   1. A single physical click can't multi-trigger off its own echo/tail
#      hitting the mic buffer threshold more than once.
#   2. The cooldown is measured from when we FINISH reacting to a click,
#      not from when we started -- so a slow 500ms buzz can't accidentally
#      "use up" the cooldown before it's even done.
click_detected = False
listening = True  # False while we are ignoring the mic (mid-buzz or in cooldown)

def i2s_callback(arg):
    # runs in background via interrupt when audio buffer is ready
    # sets click_detected flag without blocking main loop
    global click_detected
    max_val = 0
    for i in range(0, len(buf), 2):
        sample = buf[i] | (buf[i+1] << 8)
        if sample > 32767:
            sample -= 65536
        sample = abs(sample)
        if sample > max_val:
            max_val = sample

    # only register a click if we are actually listening right now.
    # this is what stops the click's own tail/reverb (which can cross
    # the threshold again on the NEXT buffer, a few ms later) from
    # queuing up a second trigger while we're still processing the first.
    if max_val > CLICK_THRESHOLD and listening:
        click_detected = True

    # queue next read immediately so mic keeps listening
    audio_in.readinto(buf)

# register callback and start first read
audio_in.irq(i2s_callback)
audio_in.readinto(buf)

print("Ready - click to scan")

while True:
    if click_detected:
        click_detected = False
        listening = False  # stop accepting new clicks until we're fully done

        # read all 3 sensors
        dist_l = get_distance(TRIG_L, ECHO_L)
        dist_c = get_distance(TRIG_C, ECHO_C)
        dist_r = get_distance(TRIG_R, ECHO_R)

        print("L: {}  C: {}  R: {}".format(dist_l, dist_c, dist_r))

        # calculate intensities for each sensor
        int_l = distance_to_intensity(dist_l)
        int_c = distance_to_intensity(dist_c)
        int_r = distance_to_intensity(dist_r)

        # direct sensor to motor mapping
        motor_far_left.duty_u16(int_l)
        motor_center.duty_u16(int_c)
        motor_far_right.duty_u16(int_r)

        # center left fires only if both left AND center sensors are active
        if is_active(dist_l) and is_active(dist_c):
            motor_center_left.duty_u16((int_l + int_c) // 2)
        else:
            motor_center_left.duty_u16(0)

        # center right fires only if both right AND center sensors are active
        if is_active(dist_r) and is_active(dist_c):
            motor_center_right.duty_u16((int_r + int_c) // 2)
        else:
            motor_center_right.duty_u16(0)

        # motors run for BUZZ_DURATION_MS then turn off
        utime.sleep_ms(BUZZ_DURATION_MS)
        all_motors_off()

        # drop any click that snuck in while we were buzzing/processing --
        # it was almost certainly an echo of the same click, not a new one
        click_detected = False

        # NOW start the cooldown, measured from the moment we actually
        # finished -- not from when we started
        cooldown_start = utime.ticks_ms()
        while utime.ticks_diff(utime.ticks_ms(), cooldown_start) < COOLDOWN_MS:
            click_detected = False  # keep ignoring anything that comes in during cooldown
            utime.sleep_ms(10)

        listening = True  # ready for a genuinely new click

    utime.sleep_ms(10)

Credits

ayal
3 projects • 1 follower
hi :)
Thanks to Dani, Santi, Mikel, Shyam, Adai.

Comments