Madlen Elise von WulffenArmin Gulbert
Created March 5, 2026

SAFE SPACE. A Home Office Intervention.

A canopy in your home office. Speak your ideal workspace. AI renders live image distortion in Touch Designer. A relationship made visible.

14
SAFE SPACE. A Home Office Intervention.

Things used in this project

Hardware components

Smartphone (camera + microphone
×1
Laptop or desktop computer
×1
Portable projector
×1
Audio / Video Cable Assembly, Ultra Slim RedMere HDMI to HDMI
Audio / Video Cable Assembly, Ultra Slim RedMere HDMI to HDMI
×1
Assorted fabric (curtains, bedsheets, offcuts)
×1
Wood dowels, plastic pipes, or broom handles (~1m each)
×4
Screws or duct tape
×1
Rope or strong string
×1

Software apps and online services

Touch Desinger
Camo App
Typeform
OpenAI WhisperAI
DreamDiffusion Real-time image generator
Windows 10
Microsoft Windows 10

Hand tools and fabrication machines

Sewing Machine

Story

Read more

Custom parts and enclosures

CCL - Cognitive Contribution Label

Schematics

TouchDesigner Node Network

Comprehensive Architecture Map

Code

Real-time speech-to-text node script

Python
Python based
"""
================================================================================
TouchDesigner  RMS Voice Transcription + Image Capture
================================================================================

A CHOP Execute DAT that listens to an RMS Analysis CHOP and automatically:
  - Starts recording when sound exceeds a threshold
  - Stops recording after a period of silence
  - Sends the WAV to OpenAI Whisper for transcription
  - Saves the transcript as a .txt file alongside the .wav
  - Saves a snapshot of a TOP (e.g. your visual output) as a .jpg

--------------------------------------------------------------------------------
NETWORK SETUP
--------------------------------------------------------------------------------

  audiodevin1
      |
      +--> audiofileout1        (Audio File Out CHOP  Record = Off)
      |
      +--> analyze1             (Analysis CHOP  Function: RMS Power)
                |
                v
           chopexec1            (THIS DAT  CHOP Execute)
                                 CHOPs      = analyze1
                                 Value Change = On

  button1
      |
      v
  chopexec_button              (optional CHOP Execute for manual override)
                                CHOPs     = button1
                                Off to On = On
                                paste same script, both callbacks are included

  transcript_output            (Text DAT  displays latest transcription)
  moviefileout1                (Movie File Out TOP  connected to your visual output)

--------------------------------------------------------------------------------
REQUIREMENTS
--------------------------------------------------------------------------------

  - TouchDesigner 2023.x or later
  - Python 3.x (bundled with TouchDesigner)
  - OpenAI API key set as environment variable:
      Windows:  setx OPENAI_API_KEY "sk-..."  (restart TD after)
      macOS:    export OPENAI_API_KEY="sk-..."

  No external Python packages required  uses only stdlib + TD built-ins.

--------------------------------------------------------------------------------
LICENSE
--------------------------------------------------------------------------------

  MIT License
  Free to use, modify, and distribute with attribution.

================================================================================
"""

import os
import io
import json
import time
import urllib.request


# 
# CONFIGURATION  edit these to match your TouchDesigner network
# 

# OpenAI API key  reads from environment variable (recommended)
# Alternatively hardcode: OPENAI_API_KEY = "sk-..."
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")

# Path to your Audio File Out CHOP
RECORDER_PATH  = "/project1/audiofileout1"

# Folder where .wav / .txt / .jpg files will be saved
# Defaults to Desktop/recordings  change to any absolute path
RECORDINGS_DIR = os.path.join(os.path.expanduser("~"), "Desktop", "recordings")

# Text DAT that will display the latest transcription
TRANSCRIPT_DAT = "/project1/transcript_output"

# Button COMP for manual start/stop override (optional)
BUTTON_PATH    = "/project1/button1"

# Movie File Out TOP for saving image snapshots (optional  set to "" to disable)
IMAGE_OUT_PATH = "/project1/moviefileout1"

# OpenAI model  "whisper-1" or "gpt-4o-transcribe"
MODEL          = "whisper-1"

# Language hint for Whisper  improves accuracy (e.g. "en", "es", "fr", "de")
# Set to "" for auto-detection
LANGUAGE       = "en"

# Maximum file size in MB before rejecting (OpenAI limit is 25MB)
MAX_FILE_MB    = 24


# 
# RMS TRIGGER SETTINGS
# 

# RMS level that triggers recording start
# Too low = background noise triggers it
# Too high = quiet speech gets missed
# Tip: watch your analyze1 CHOP values while silent to find your noise floor,
#      then set this to roughly double that value
RMS_THRESHOLD    = 0.08

# Seconds of silence before recording stops and transcription begins
SILENCE_TIMEOUT  = 2.0

# Seconds to wait after a take ends before allowing a new auto-recording
# Prevents immediate re-triggering from residual noise
RESTART_COOLDOWN = 3.0


# 
# INTERNAL STATE  do not edit
# 

_last_sound_time = 0.0
_last_stop_time  = 0.0
_image_saved     = False


# 
# INTERNAL HELPERS
# 

def _rec():
    node = op(RECORDER_PATH)
    if node is None:
        debug("WHISPER: recorder node not found at " + RECORDER_PATH)
    return node


def _set_label(label):
    if not BUTTON_PATH:
        return
    btn = op(BUTTON_PATH)
    if btn:
        btn.par.label = label


def _is_recording():
    node = _rec()
    return bool(node.par.record.val) if node else False


def _save_transcript(wav_path, text):
    txt_path = wav_path.replace(".wav", ".txt")
    try:
        with open(txt_path, "w", encoding="utf-8") as f:
            f.write(text)
        debug("WHISPER: transcript saved -> " + txt_path)
    except Exception as e:
        debug("WHISPER: could not save transcript - " + str(e))


def _save_image(wav_path):
    if not IMAGE_OUT_PATH:
        return
    top = op(IMAGE_OUT_PATH)
    if top is None:
        debug("IMAGE: node not found at " + IMAGE_OUT_PATH)
        return
    img_path = wav_path.replace(".wav", ".jpg")
    try:
        top.par.file = img_path
        top.save(img_path)
        debug("IMAGE: saved -> " + img_path)
    except Exception as e:
        debug("IMAGE: save failed - " + str(e))


def _start_recording():
    global _image_saved
    node = _rec()
    if node is None:
        return ""
    node.par.record = 0
    time.sleep(0.1)
    os.makedirs(RECORDINGS_DIR, exist_ok=True)
    ts       = time.strftime("%Y%m%d_%H%M%S")
    wav_path = os.path.join(RECORDINGS_DIR, "take_" + ts + ".wav")
    node.par.file   = wav_path
    node.par.record = 1
    _image_saved    = False
    tdat = op(TRANSCRIPT_DAT)
    if tdat:
        tdat.text = ""
    _set_label("[ REC ]")
    debug("WHISPER: recording started -> " + wav_path)
    return wav_path


def _stop_recording():
    global _last_stop_time
    node = _rec()
    if node is None:
        return ""
    wav_path        = str(node.par.file)
    node.par.record = 0
    _last_stop_time = time.time()
    _set_label("Sending...")
    debug("WHISPER: recording stopped -> " + wav_path)
    return wav_path


def _multipart_body(fields, files, boundary):
    body = io.BytesIO()
    enc  = boundary.encode()
    for name, value in fields.items():
        body.write(b"--" + enc + b"\r\n")
        body.write(('Content-Disposition: form-data; name="' + name + '"\r\n\r\n').encode())
        body.write(str(value).encode())
        body.write(b"\r\n")
    for name, (filename, ctype, data) in files.items():
        body.write(b"--" + enc + b"\r\n")
        body.write(('Content-Disposition: form-data; name="' + name + '"; filename="' + filename + '"\r\n').encode())
        body.write(('Content-Type: ' + ctype + '\r\n\r\n').encode())
        body.write(data)
        body.write(b"\r\n")
    body.write(b"--" + enc + b"--\r\n")
    return body.getvalue()


def _transcribe(wav_path):
    if not OPENAI_API_KEY:
        debug("WHISPER: OPENAI_API_KEY not set  set env var and restart TouchDesigner")
        _set_label("Press to record")
        return

    debug("WHISPER: waiting for file flush...")
    time.sleep(0.8)

    if not os.path.isfile(wav_path):
        debug("WHISPER: file not found: " + wav_path)
        _set_label("Press to record")
        return

    size_bytes = os.path.getsize(wav_path)
    size_mb    = size_bytes / (1024 * 1024)

    if size_bytes < 1000:
        debug("WHISPER: file too small - no speech detected")
        _set_label("Press to record")
        return

    if size_mb > MAX_FILE_MB:
        debug("WHISPER: file too large (" + str(round(size_mb, 1)) + " MB) - keep under 2 minutes")
        _set_label("Press to record")
        return

    debug("WHISPER: sending " + str(round(size_mb, 2)) + " MB to OpenAI...")

    with open(wav_path, "rb") as f:
        wav_bytes = f.read()

    fields   = {"model": MODEL, "response_format": "json"}
    if LANGUAGE:
        fields["language"] = LANGUAGE

    boundary = "TDBoundary7MA4YWxkTrZu0gW"
    body     = _multipart_body(
        fields,
        {"file": ("audio.wav", "audio/wav", wav_bytes)},
        boundary,
    )
    req = urllib.request.Request(
        "https://api.openai.com/v1/audio/transcriptions",
        data    = body,
        method  = "POST",
        headers = {
            "Authorization": "Bearer " + OPENAI_API_KEY,
            "Content-Type" : "multipart/form-data; boundary=" + boundary,
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            raw  = resp.read().decode()
            debug("WHISPER: response -> " + raw)
            text = (json.loads(raw).get("text") or "").strip()
    except urllib.error.HTTPError as e:
        debug("WHISPER: HTTP " + str(e.code) + " - " + e.read().decode("utf-8", errors="replace"))
        _set_label("Press to record")
        return
    except Exception as e:
        debug("WHISPER: error - " + str(e))
        _set_label("Press to record")
        return

    if not text:
        debug("WHISPER: empty result - speak clearly and close to mic")
        _set_label("Press to record")
        return

    debug("WHISPER: result -> " + text)
    _save_transcript(wav_path, text)

    tdat = op(TRANSCRIPT_DAT)
    if tdat is None:
        debug("WHISPER: transcript DAT not found at " + TRANSCRIPT_DAT)
        _set_label("Press to record")
        return

    tdat.text = text
    debug("WHISPER: transcript_output updated")
    _set_label("Press to record")


# 
# MANUAL TOGGLE  triggered by button CHOP Execute
# 

def toggle():
    """Start or stop recording manually via button press."""
    if _is_recording():
        debug("WHISPER: manual STOP")
        wav_path = _stop_recording()
        if wav_path:
            _save_image(wav_path)
            _transcribe(wav_path)
    else:
        debug("WHISPER: manual START")
        _start_recording()


# 
# RMS AUTO-TRIGGER  triggered by Analysis CHOP Execute
# 

def on_rms(rms_value):
    """
    Called on every RMS value change from the Analysis CHOP.
    Starts recording when sound exceeds RMS_THRESHOLD.
    Stops recording after SILENCE_TIMEOUT seconds of silence.
    """
    global _last_sound_time

    now = time.time()

    if rms_value >= RMS_THRESHOLD:
        _last_sound_time = now

        if not _is_recording():
            if now - _last_stop_time >= RESTART_COOLDOWN:
                debug("WHISPER: RMS " + str(round(rms_value, 4)) + "  auto starting")
                _start_recording()
            else:
                remaining = round(RESTART_COOLDOWN - (now - _last_stop_time), 1)
                debug("WHISPER: cooldown " + str(remaining) + "s remaining")
    else:
        if _is_recording():
            silence_duration = now - _last_sound_time
            if silence_duration >= SILENCE_TIMEOUT:
                debug("WHISPER: " + str(round(silence_duration, 1)) + "s silence  auto stopping")
                wav_path = _stop_recording()
                if wav_path:
                    _save_image(wav_path)
                    _transcribe(wav_path)


# 
# TOUCHDESIGNER CALLBACKS
# 

def onOffToOn(channel, sampleIndex, val, prev):
    """Fires when button value goes from 0 to 1 (button pressed)."""
    toggle()


def onValueChange(channel, sampleIndex, val, prev):
    """Fires on every RMS value change from the Analysis CHOP."""
    on_rms(val)

Credits

Madlen Elise von Wulffen
3 projects • 1 follower
Armin Gulbert
3 projects • 1 follower

Comments