Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions triggers/welcome-party/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
audio-cache/
49 changes: 49 additions & 0 deletions triggers/welcome-party/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Welcome Party

The most obnoxious call join trigger ever created. When someone joins your call, they get the full treatment: AI-generated voice announcements, themed sound effects, and a full-screen confetti explosion.

## What happens

Every join randomly picks one of 5 themes:

| Theme | SFX | Voice |
|-------|-----|-------|
| **WWE** | Pyrotechnics + metal riff | "THE UNDEFEATED! THE UNMATCHED!" |
| **Royal** | Trumpet fanfare | "All rise! The magnificent [name] has arrived!" |
| **Sports** | Arena crowd + organ | "Number ONE in our hearts!" |
| **Movie Trailer** | Cinematic booms | "In a world where calls are boring..." |
| **DJ Hype** | Dubstep buildup + drop | "OH MY GOD! [name] IS HERE!" |

Each theme also has matching emoji and subtitle in the confetti page.

## Requirements

- **ElevenLabs API key** with text-to-speech access — set `ELEVENLABS_API_KEY` in your environment
- **macOS** (uses `afplay`, `afinfo`, `open`, and `python3`)
- **Tuple** with triggers enabled (Preferences > Triggers > Enable)

## Setup

1. Copy the `welcome-party` folder to `~/.tuple/triggers/`
2. Make the trigger executable: `chmod +x ~/.tuple/triggers/welcome-party/participant-joined`
3. Set your ElevenLabs API key: `export ELEVENLABS_API_KEY="your-key-here"` in your shell profile
4. Enable triggers in Tuple Preferences

## How it works

- Uses **ElevenLabs v3** with the **Xavier** voice (Dominating, Metallic Announcer) and audio tags like `[screaming]`, `[explosion]`, and `[crowd erupts]` for expressive delivery
- Sound effects were generated with ElevenLabs Sound Effects and Music Generation APIs
- Voice lines are **cached** by content hash in `audio-cache/` so repeat announcements are instant and don't burn API credits
- The confetti page fades out and the browser tab is closed via AppleScript when audio finishes

## Testing

Use the theme override to test a specific theme:

```bash
TUPLE_TRIGGER_THEME_OVERRIDE=dj-hype \
TUPLE_TRIGGER_FULL_NAME="Your Name" \
~/.tuple/triggers/welcome-party/participant-joined
```

Or use Tuple's built-in trigger tester: Preferences > Triggers > Test Triggers.
Binary file added triggers/welcome-party/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
159 changes: 159 additions & 0 deletions triggers/welcome-party/confetti.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>WELCOME TO THE CALL</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a2e;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: -apple-system, system-ui, sans-serif;
}
.hero {
text-align: center;
z-index: 10;
position: relative;
animation: heroEntrance 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
opacity: 0;
transform: scale(0.3);
}
@keyframes heroEntrance {
to { opacity: 1; transform: scale(1); }
}
.name {
font-size: 5vw;
font-weight: 900;
background: linear-gradient(135deg, #ff6b6b, #ffd93d, #6bcb77, #4d96ff, #ff6b6b);
background-size: 400% 400%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: rainbow 2s ease infinite;
text-transform: uppercase;
letter-spacing: 0.05em;
filter: drop-shadow(0 0 30px rgba(255,107,107,0.5));
}
@keyframes rainbow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.subtitle {
font-size: 2.5vw;
color: #ffd93d;
margin-top: 20px;
animation: pulse 0.5s ease infinite alternate;
text-shadow: 0 0 20px rgba(255,217,61,0.8);
}
@keyframes pulse { to { transform: scale(1.05); opacity: 0.8; } }
.emoji-row {
font-size: 4vw;
margin-top: 15px;
animation: bounce 0.6s ease infinite alternate;
}
@keyframes bounce { to { transform: translateY(-15px); } }
canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
.spotlight {
position: fixed;
width: 200vw;
height: 200vh;
top: -50vh;
left: -50vw;
background: conic-gradient(from 0deg, transparent, rgba(255,107,107,0.1), transparent, rgba(77,150,255,0.1), transparent, rgba(107,203,119,0.1), transparent);
animation: spin 3s linear infinite;
z-index: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }

</style>
</head>
<body>
<div class="spotlight"></div>
<canvas id="confetti"></canvas>
<div class="hero">
<div class="emoji-row" id="emojis"></div>
<div class="name" id="name"></div>
<div class="subtitle" id="subtitle"></div>
<div class="emoji-row" style="animation-delay: 0.3s" id="emojis2"></div>
</div>

<script>
const name = window.TUPLE_NAME || 'A LEGEND';
const theme = window.TUPLE_THEME || 'wwe';
const firstName = name.split(' ')[0];

const themes = {
'wwe': { emojis: ['💪','🔥','⚡','💥','🏆','👊','🤼','💣'], subtitle: `${firstName.toUpperCase()} HAS ENTERED THE ARENA` },
'royal': { emojis: ['👑','🏰','⚜️','🦁','🗡️','💎','🎺','👸'], subtitle: `ALL RISE FOR ${firstName.toUpperCase()}` },
'sports': { emojis: ['🏟️','🏅','📣','⭐','🥇','🎯','🙌','🔥'], subtitle: `NUMBER ONE IN OUR HEARTS` },
'movie-trailer': { emojis: ['🎬','🍿','🎥','🌑','💀','⚡','🎞️','🔮'], subtitle: `COMING SOON TO A CALL NEAR YOU` },
'dj-hype': { emojis: ['🎧','🪩','🔊','🎵','💃','🕺','🎤','🔥'], subtitle: `${firstName.toUpperCase()} IS IN THE BUILDING` },
};

const t = themes[theme] || themes['wwe'];
document.getElementById('emojis').textContent = t.emojis.slice(0, 4).join(' ');
document.getElementById('emojis2').textContent = t.emojis.slice(4).join(' ');
document.getElementById('name').textContent = name;
document.getElementById('subtitle').textContent = t.subtitle;

// Confetti engine
const canvas = document.getElementById('confetti');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const colors = ['#ff6b6b','#ffd93d','#6bcb77','#4d96ff','#ff6bff','#ff9f43','#00d2d3','#ff4757'];
const confetti = [];
const TOTAL = 300;

for (let i = 0; i < TOTAL; i++) {
confetti.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height - canvas.height,
w: Math.random() * 12 + 5,
h: Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
speed: Math.random() * 4 + 2,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.2,
drift: (Math.random() - 0.5) * 2,
wobble: Math.random() * 10,
wobbleSpeed: Math.random() * 0.1 + 0.05,
});
}

function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
confetti.forEach(c => {
ctx.save();
ctx.translate(c.x, c.y);
ctx.rotate(c.angle);
ctx.fillStyle = c.color;
ctx.globalAlpha = Math.min(1, (canvas.height - c.y) / canvas.height + 0.3);
ctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
ctx.restore();
c.y += c.speed;
c.x += c.drift + Math.sin(c.wobble) * 0.5;
c.wobble += c.wobbleSpeed;
c.angle += c.spin;
if (c.y > canvas.height + 20) {
c.y = -20;
c.x = Math.random() * canvas.width;
}
});
requestAnimationFrame(draw);
}
draw();

// Fade out before the trigger closes the tab
const closeSec = window.TUPLE_CLOSE_AFTER || 15;
setTimeout(() => {
document.body.style.transition = 'opacity 0.5s';
document.body.style.opacity = '0';
}, (closeSec - 1) * 1000);
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions triggers/welcome-party/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "Welcome Party",
"description": "The most obnoxious welcome trigger ever created. Uses ElevenLabs v3 with Xavier (Dominating, Metallic Announcer) and audio tags to announce participants with randomly themed entrances — WWE, royal fanfare, sports arena, movie trailer, or DJ hype — complete with full-screen confetti, generated sound effects, and AI voice announcements. Requires an ElevenLabs API key.",
"platforms": ["macos"],
"language": "bash"
}
120 changes: 120 additions & 0 deletions triggers/welcome-party/participant-joined
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/bin/bash
#
# THE MOST OBNOXIOUS WELCOME TRIGGER EVER CREATED
# Xavier (Dominating, Metallic Announcer) + ElevenLabs v3 audio tags.

[[ -f "${HOME}/.zshrc.local" ]] && source "${HOME}/.zshrc.local"

NAME="${TUPLE_TRIGGER_FULL_NAME:-A Mystery Legend}"
FIRST_NAME="${NAME%% *}"
TRIGGER_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFETTI_HTML="${TRIGGER_DIR}/confetti.html"
SFX_DIR="${TRIGGER_DIR}/sfx"
CACHE_DIR="${TRIGGER_DIR}/audio-cache"
ELEVENLABS_API_KEY="${ELEVENLABS_API_KEY:-}"

VOICE="YOq2y2Up4RgXP2HyXjE5" # Xavier - Dominating, Metallic Announcer
MODEL="eleven_v3"

mkdir -p "${CACHE_DIR}"

TEMP_HTML="$(mktemp /tmp/tuple-welcome-XXXXXX).html"
trap 'sleep 1; rm -f "${TEMP_HTML}" "${TEMP_HTML%.html}"' EXIT

elevenlabs_generate() {
local text="$1"
local cache_key
cache_key=$(echo "${VOICE}:${MODEL}:${text}" | md5 -q)
local cache_file="${CACHE_DIR}/${cache_key}.mp3"

if [[ ! -f "${cache_file}" ]]; then
if [[ -z "${ELEVENLABS_API_KEY}" ]]; then
return 1
fi

local payload
payload=$(python3 -c "import json,sys; print(json.dumps({'text': sys.argv[1], 'model_id': sys.argv[2], 'voice_settings': {'stability': 0.3, 'similarity_boost': 0.85, 'style': 0.7}}))" "${text}" "${MODEL}" 2>/dev/null)

local tmp_file="${cache_file}.tmp"
local http_code
http_code=$(curl -s "https://api.elevenlabs.io/v1/text-to-speech/${VOICE}" \
-H "xi-api-key: ${ELEVENLABS_API_KEY}" \
-H "Content-Type: application/json" \
-d "${payload}" \
--output "${tmp_file}" -w "%{http_code}" 2>/dev/null)

if [[ "${http_code}" == "200" ]] && [[ -s "${tmp_file}" ]] && file "${tmp_file}" 2>/dev/null | grep -q "Audio"; then
mv "${tmp_file}" "${cache_file}"
else
rm -f "${tmp_file}"
return 1
fi
fi

echo "${cache_file}"
}

get_duration() { afinfo "$1" 2>/dev/null | awk '/estimated duration/ {print $3}'; }

THEMES=(wwe royal sports movie-trailer dj-hype)
THEME="${TUPLE_TRIGGER_THEME_OVERRIDE:-${THEMES[$((RANDOM % ${#THEMES[@]}))]}}"

case "${THEME}" in
wwe)
SFX_FILE="${SFX_DIR}/wwe-entrance.mp3"
VOICE_TEXT="[booming stadium announcer] THE UNDEFEATED! THE UNMATCHED! [explosion] ${NAME}!"
;;
royal)
SFX_FILE="${SFX_DIR}/royal-fanfare.mp3"
VOICE_TEXT="[deep, regal herald] [trumpet fanfare] All rise! The magnificent ${NAME} has arrived! [pause] Let the proceedings commence!"
;;
sports)
SFX_FILE="${SFX_DIR}/arena-crowd.mp3"
VOICE_TEXT="[stadium announcer, echoing] Number ONE in our hearts! [crowd chanting] [shouting] ${NAME}! [crowd erupts]"
;;
movie-trailer)
SFX_FILE="${SFX_DIR}/movie-trailer.mp3"
VOICE_TEXT="[deep cinematic trailer voice, slow] In a world where calls are boring [pause] one person dared to join. [dramatic boom] ${NAME}."
;;
dj-hype)
SFX_FILE="${SFX_DIR}/dj-hype.mp3"
VOICE_TEXT="[screaming] OH MY GOD! [air horn] ${FIRST_NAME} IS HERE! [explosion] THIS IS NOT A DRILL!"
;;
esac

VOICE_FILE=$(elevenlabs_generate "${VOICE_TEXT}")

SFX_DUR=$(get_duration "${SFX_FILE}")
VOICE_DUR=0
if [[ -n "${VOICE_FILE}" ]]; then
VOICE_DUR=$(get_duration "${VOICE_FILE}")
fi
[[ "${SFX_DUR}" =~ ^[0-9.]+$ ]] || SFX_DUR=10
[[ "${VOICE_DUR}" =~ ^[0-9.]+$ ]] || VOICE_DUR=0
CLOSE_AFTER=$(awk "BEGIN {d=$SFX_DUR; if ($VOICE_DUR>d) d=$VOICE_DUR; printf \"%d\", d+1}")

# Inject name, theme, and close timer via python3 for safe escaping
python3 -c "
import sys, json
name = sys.argv[1]
theme = sys.argv[2]
close_after = sys.argv[3]
with open(sys.argv[4]) as f:
html = f.read()
html = html.replace('window.TUPLE_NAME', json.dumps(name))
html = html.replace('window.TUPLE_THEME', json.dumps(theme))
html = html.replace('window.TUPLE_CLOSE_AFTER', close_after)
with open(sys.argv[5], 'w') as f:
f.write(html)
" "${NAME}" "${THEME}" "${CLOSE_AFTER}" "${CONFETTI_HTML}" "${TEMP_HTML}"

# Open a native WebKit window (no browser needed, closes itself)
"${TRIGGER_DIR}/webview" "${TEMP_HTML}" "${CLOSE_AFTER}" &
WEBVIEW_PID=$!

afplay "${SFX_FILE}" &
if [[ -n "${VOICE_FILE}" ]]; then
afplay "${VOICE_FILE}" &
fi
wait
wait $WEBVIEW_PID 2>/dev/null
Binary file added triggers/welcome-party/sfx/arena-crowd.mp3
Binary file not shown.
Binary file added triggers/welcome-party/sfx/dj-hype.mp3
Binary file not shown.
Binary file added triggers/welcome-party/sfx/movie-trailer.mp3
Binary file not shown.
Binary file added triggers/welcome-party/sfx/royal-fanfare.mp3
Binary file not shown.
Binary file added triggers/welcome-party/sfx/wwe-entrance.mp3
Binary file not shown.
41 changes: 41 additions & 0 deletions triggers/welcome-party/webview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Cocoa
import WebKit

let args = CommandLine.arguments
guard args.count >= 3 else { exit(1) }
let url = URL(fileURLWithPath: args[1])
let closeAfter = Double(args[2]) ?? 15.0

let app = NSApplication.shared
app.setActivationPolicy(.accessory)

let screen = NSScreen.main!
let size = NSSize(width: 800, height: 500)
let origin = NSPoint(
x: screen.frame.midX - size.width / 2,
y: screen.frame.midY - size.height / 2
)
let window = NSWindow(
contentRect: NSRect(origin: origin, size: size),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.level = .floating
window.backgroundColor = .black
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]

let webView = WKWebView(frame: window.contentView!.bounds)
webView.autoresizingMask = [.width, .height]
window.contentView!.addSubview(webView)
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
window.makeKeyAndOrderFront(nil)

DispatchQueue.main.asyncAfter(deadline: .now() + closeAfter) {
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.5
window.animator().alphaValue = 0
}) { app.terminate(nil) }
}

app.run()