• 01 - Critter Models 02
  • 02 - VantaJS Shaders
  • 03 - App Bundle 02
  • 04 - Cathedrals
  • 05 - App Bundle 01
  • 06 - Retroscopic
  • 07 - Games
  • 08 - Voronoi
  • 09 - Terrain
  • 10 - Critter Models 01
  • 11 - Chromatophores
  • 12 - Conformal Maps
  • 13 - Echo Logix
  • 14 - Music
  • 15 - Colour Theory
MIKE'S NOTEBOOK
Notes
Number:
Project:
01Critter Models 02
└ Locust└ Butterfly└ Cockroach└ Spider└ Scorpion└ Dragon└ Brazilian Treehopper└ Tardigrade└ Diatoms└ Bumblebee└ Wasp└ Hammerhead Worm└ Giraffe Weevil└ Trilobite Beetle
02VantaJS Shaders
└ Clouds└ Waves└ Cells└ Rings└ Halo└ Globe└ Net└ Dots└ Birds└ Trunk└ Topology└ About VantaJS
03App Bundle 02
└ Gutindex└ Insect Builder└ OpenLayers
04Cathedrals
└ Lattice└ Lost└ Lucent└ Lumen└ Glass└ Disco└ Hyperion└ Latent
05App Bundle 01
└ Robohash└ PrismJS└ Horoscope└ Binary Jazz└ Morse Code└ URL Extractor└ Entity Escaper└ Phonetic Alphabet└ Toki Pona└ Semaphore└ Cut Up Machine└ Text Mixer└ Currency Freaks└ Host IO└ CrUX API└ Logo Dev└ Web Speech API└ GEO DB
06Retroscopic
└ Galaxy└ Ocean└ Slime└ 24-Cell└ Unicorn└ Polyp└ Oneiric 1└ Oneiric 2└ Node└ Torus Knot└ GLSL Grid└ Liquid Crystal└ Silky└ Butterfly└ Ocean 2└ Icosahedron└ Luma└ Swarm└ Broken Solid└ Hypersphere└ Constraints└ Psycho Killer
01 / 03

WEB SPEECH-API - INTERACTIVE APP SOURCE CODE

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SpeechSynthesis TTS App</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
        }

        :root {
            color-scheme: light dark;
            --bg-color: transparent;
            --surface: #111111;
            --surface-light: #222222;
            --surface-lighter: #333333;
            --text-color: #ffffff;
            --text-secondary: #aaaaaa;
            --border-color: #444444;
            --select-bg: #000000;
            --select-border: #444444;
            --accent-white: #ffffff;
            --accent-light: #f0f0f0;
            --accent-dark: #cccccc;
        }

        html:not(.dark) {
            --bg-color: transparent;
            --surface: #f9f9f9;
            --surface-light: #f0f0f0;
            --surface-lighter: #e0e0e0;
            --text-color: #111111;
            --text-secondary: #555555;
            --border-color: #cccccc;
            --select-bg: #ffffff;
            --select-border: #cccccc;
            --accent-white: #111111;
            --accent-light: #333333;
            --accent-dark: #555555;
        }

        body {
            background-color: var(--bg-color);
            color: var(--text-color);
            height: 100vh;
            display: flex;
            flex-direction: column;
            overflow: hidden;
        }

        .app-container {
            display: flex;
            flex-direction: column;
            height: 100vh;
            width: 100%;
            gap: 24px;
            overflow: hidden;
        }

        .main-content {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 24px;
            flex: 1;
            min-height: 0;
            overflow: hidden;
        }

        @media (max-width: 1024px) {
            .main-content {
                grid-template-columns: 1fr;
            }
        }

        .panel {
            display: flex;
            flex-direction: column;
            height: 100%;
        }

        .panel-title {
            display: flex;
            align-items: center;
            gap: 12px;
            margin-bottom: 20px;
            padding-bottom: 16px;
            border-bottom: 1px solid var(--border-color);
        }

        .panel-title i {
            color: var(--accent-white);
            font-size: 18px;
        }

        .panel-title h2 {
            font-size: 20px;
            font-weight: 600;
            color: var(--accent-white);
        }

        .text-input-container {
            display: flex;
            flex-direction: column;
            flex: 1;
            overflow: hidden;
            min-height: 0;
            gap: 16px;
        }

        .warning-banner {
            background-color: rgba(255, 255, 255, 0.05);
            border-left: 3px solid var(--accent-white);
            padding: 12px 16px;
            border-radius: 6px;
            font-size: 13px;
            color: var(--text-color);
            line-height: 1.5;
        }

        .warning-banner i {
            color: var(--accent-white);
            margin-right: 8px;
            font-size: 14px;
        }

        #textInput {
            flex: 1;
            background-color: var(--surface-light);
            color: var(--text-color);
            border: 1px solid var(--border-color);
            border-radius: 8px;
            padding: 16px;
            font-size: 15px;
            line-height: 1.6;
            resize: none;
            transition: border-color 0.2s;
            min-height: 120px;
        }

        #textInput:focus {
            outline: none;
            border-color: var(--accent-white);
        }

        .input-footer {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding-top: 12px;
            border-top: 1px solid var(--border-color);
        }

        .char-count {
            color: var(--text-secondary);
            font-size: 13px;
        }

        .language-match {
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 12px;
            padding: 6px 12px;
            border-radius: 6px;
            background-color: rgba(255, 255, 255, 0.05);
            border: 1px solid var(--border-color);
        }

        .language-match-icon {
            font-size: 12px;
            color: var(--accent-white);
        }

        .action-buttons {
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 12px;
            padding-top: 20px;
            border-top: 1px solid var(--border-color);
            flex-shrink: 0;
        }

        .btn {
            padding: 12px;
            border: 1px solid var(--border-color);
            border-radius: 8px;
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            transition: all 0.2s;
            background-color: var(--surface-light);
            color: var(--text-color);
            user-select: none;
            -webkit-tap-highlight-color: transparent;
            touch-action: manipulation;
        }

        .btn:hover {
            background-color: var(--surface-lighter);
            transform: translateY(-1px);
        }

        .btn:active {
            transform: translateY(0);
        }

        .btn:disabled {
            opacity: 0.4;
            cursor: not-allowed;
            transform: none;
        }

        .btn-primary {
            background-color: var(--accent-white);
            color: var(--surface);
            border: none;
            font-weight: 600;
        }

        .btn-primary:hover {
            background-color: var(--accent-light);
        }

        .btn-danger {
            background-color: rgba(255, 255, 255, 0.1);
            border-color: rgba(255, 255, 255, 0.3);
        }

        .btn-danger:hover {
            background-color: rgba(255, 255, 255, 0.2);
        }

        .language-examples {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            flex-shrink: 0;
        }

        .lang-btn {
            padding: 6px 12px;
            background-color: var(--surface-light);
            color: var(--text-color);
            border: 1px solid var(--border-color);
            border-radius: 6px;
            font-size: 12px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .lang-btn:hover {
            background-color: var(--surface-lighter);
            border-color: var(--accent-white);
        }

        /* VOICE CONTROLS PANEL STYLING */
        .controls-panel {
            display: flex;
            flex-direction: column;
            height: 100%;
        }

        .voice-controls {
            display: flex;
            flex-direction: column;
            flex: 1;
            min-height: 0;
            gap: 24px;
            overflow: hidden;
        }

        .control-group {
            display: flex;
            flex-direction: column;
            gap: 12px;
            flex-shrink: 0;
        }

        .control-label {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .control-label span {
            font-size: 14px;
            font-weight: 500;
            color: var(--accent-white);
        }

        .control-value {
            color: var(--accent-white);
            font-weight: 600;
            font-size: 13px;
        }

        select {
            background-color: var(--select-bg);
            color: var(--text-color);
            border: 1px solid var(--border-color);
            border-radius: 8px;
            padding: 12px;
            font-size: 14px;
            cursor: pointer;
            transition: border-color 0.2s;
            appearance: none;
            background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3e%3cpath d='M7 10l5 5 5-5z'/%3e%3c/svg%3e");
            background-repeat: no-repeat;
            background-position: right 12px center;
            background-size: 16px;
            padding-right: 40px;
            flex-shrink: 0;
        }

        select:focus {
            outline: none;
            border-color: var(--accent-white);
        }

        select option {
            background-color: var(--select-bg);
            color: var(--text-color);
            padding: 12px;
        }

        .slider-container {
            display: flex;
            align-items: center;
            gap: 12px;
        }

        .slider-icon {
            color: var(--accent-white);
            font-size: 14px;
            width: 20px;
            text-align: center;
            flex-shrink: 0;
        }

        input[type="range"] {
            flex-grow: 1;
            height: 4px;
            -webkit-appearance: none;
            appearance: none;
            background: var(--surface-light);
            border-radius: 2px;
            outline: none;
            flex-shrink: 1;
        }

        input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 18px;
            height: 18px;
            border-radius: 50%;
            background: var(--accent-white);
            cursor: pointer;
            border: 2px solid var(--bg-color);
        }

        input[type="range"]::-moz-range-thumb {
            width: 18px;
            height: 18px;
            border-radius: 50%;
            background: var(--accent-white);
            cursor: pointer;
            border: 2px solid var(--bg-color);
        }

        .voice-info {
            display: flex;
            flex-direction: column;
            gap: 8px;
            font-size: 12px;
            color: var(--text-secondary);
            background-color: var(--surface-light);
            padding: 16px;
            border-radius: 8px;
            border: 1px solid var(--border-color);
            flex-shrink: 0;
        }

        .voice-detail {
            display: flex;
            justify-content: space-between;
        }

        .voice-detail-label {
            font-weight: 500;
            color: var(--text-secondary);
        }

        .voice-detail-value {
            color: var(--accent-white);
        }

        .info-tooltip {
            display: inline-block;
            margin-left: 8px;
            color: var(--accent-white);
            cursor: help;
            position: relative;
        }

        .info-tooltip .tooltip-text {
            visibility: hidden;
            width: 280px;
            background-color: var(--surface);
            color: var(--text-color);
            text-align: left;
            border-radius: 6px;
            padding: 12px;
            position: absolute;
            z-index: 1000;
            bottom: 125%;
            left: 50%;
            transform: translateX(-50%);
            border: 1px solid var(--border-color);
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
            opacity: 0;
            transition: opacity 0.3s;
            font-size: 12px;
            line-height: 1.5;
        }

        .info-tooltip:hover .tooltip-text {
            visibility: visible;
            opacity: 1;
        }

        .status-container {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 24px;
        }

        .status-indicator {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 8px 16px;
            background-color: var(--surface);
            border: 1px solid var(--border-color);
            border-radius: 20px;
            font-size: 13px;
        }

        .status-dot {
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background-color: var(--accent-white);
            opacity: 0.5;
        }

        .status-dot.active {
            opacity: 1;
            box-shadow: 0 0 8px var(--accent-white);
            animation: pulse 1.5s infinite;
        }

        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.7; }
            100% { opacity: 1; }
        }

        /* RESPONSIVE DESIGN FIXES */
        @media (max-width: 768px) {
            .app-container {
                gap: 16px;
            }

            .panel {
                min-height: min-content;
            }

            .main-content {
                gap: 16px;
            }

            .text-input-container {
                overflow: visible;
                min-height: min-content;
            }

            .action-buttons {
                grid-template-columns: repeat(2, 1fr);
                gap: 8px;
                margin-top: 16px;
                padding-top: 16px;
                position: sticky;
                bottom: 0;
                background-color: var(--surface);
                z-index: 10;
            }

            .btn {
                padding: 14px;
                font-size: 14px;
                min-height: 50px;
            }

            #textInput {
                max-height: 200px;
            }
        }

        @media (max-width: 480px) {
            .action-buttons {
                grid-template-columns: repeat(2, 1fr);
                position: fixed;
                bottom: 16px;
                left: 16px;
                right: 16px;
                background-color: var(--surface);
                border: 1px solid var(--border-color);
                border-radius: 12px;
                padding: 12px;
                margin: 0;
                box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
                z-index: 1000;
            }

            .btn {
                height: 50px;
                font-size: 14px;
            }

            .input-footer {
                flex-direction: column;
                gap: 12px;
                align-items: flex-start;
            }

            .language-examples {
                justify-content: center;
            }

            .lang-btn {
                font-size: 11px;
                padding: 5px 10px;
            }

            .voice-controls {
                gap: 20px;
            }

            .control-group {
                gap: 10px;
            }
        }

        @media (max-width: 360px) {
            .action-buttons {
                grid-template-columns: 1fr;
                gap: 8px;
            }

            .btn {
                height: 46px;
            }


            .panel-title h2 {
                font-size: 18px;
            }
        }
    </style>
</head>
<body>
    <div class="app-container">
        <div class="main-content">
            <!-- TEXT INPUT PANEL -->
            <div class="panel">


                <div class="text-input-container">
                    <div class="warning-banner">
                        <i class="fas fa-exclamation-triangle"></i>
                        <strong>Important:</strong> This tool speaks text using different voices, but does NOT translate between languages.
                    </div>

                    <div class="language-examples">
                        <button class="lang-btn" data-lang="en">English</button>
                        <button class="lang-btn" data-lang="es">Spanish</button>
                        <button class="lang-btn" data-lang="fr">French</button>
                        <button class="lang-btn" data-lang="de">German</button>
                        <button class="lang-btn" data-lang="it">Italian</button>
                        <button class="lang-btn" data-lang="ja">Japanese</button>
                        <button class="lang-btn" data-lang="zh">Chinese</button>
                    </div>

                    <textarea id="textInput" placeholder="Enter text to convert to speech... For best results, match the language of your text with the selected voice's language.">Welcome to the Speech Synthesis demonstration. This tool converts text to speech using different voices and accents. Remember: this is NOT a translation tool. Each voice speaks in a specific language. For clear pronunciation, use text that matches your selected voice's language.</textarea>

                    <div class="input-footer">
                        <div class="char-count">
                            <span id="charCount">0</span> characters
                        </div>
                        <div id="languageMatch" class="language-match">
                            <i class="fas fa-exclamation-circle language-match-icon"></i>
                            <span>Select a voice to check language compatibility</span>
                        </div>
                    </div>

                    <div class="action-buttons">
                        <button class="btn btn-primary" id="speakBtn">
                            <i class="fas fa-play"></i> Play
                        </button>
                        <button class="btn" id="pauseBtn" disabled>
                            <i class="fas fa-pause"></i> Pause
                        </button>
                        <button class="btn" id="resumeBtn" disabled>
                            <i class="fas fa-play"></i> Resume
                        </button>
                        <button class="btn btn-danger" id="stopBtn" disabled>
                            <i class="fas fa-stop"></i> Stop
                        </button>
                    </div>
                </div>
            </div>

            <!-- VOICE CONTROLS PANEL -->
            <div class="panel controls-panel">


                <div class="voice-controls">
                    <div class="control-group">
                        <div class="control-label">
                            <span>Voice Selection <small>(Language/Accent)</small></span>
                        </div>
                        <select id="voiceSelect">
                            <option value="">Loading voices...</option>
                        </select>

                        <div id="selectedVoiceInfo" class="voice-info" style="display: none;">
                            <div class="voice-detail">
                                <span class="voice-detail-label">Language:</span>
                                <span class="voice-detail-value" id="voiceLanguage">-</span>
                            </div>
                            <div class="voice-detail">
                                <span class="voice-detail-label">Accent/Voice Type:</span>
                                <span class="voice-detail-value" id="voiceType">-</span>
                            </div>
                            <div class="voice-detail">
                                <span class="voice-detail-label">Local Service:</span>
                                <span class="voice-detail-value" id="voiceLocal">-</span>
                            </div>
                        </div>
                    </div>

                    <div class="control-group">
                        <div class="control-label">
                            <span>Rate (Speed)</span>
                            <span class="control-value" id="rateValue">1.0</span>
                        </div>
                        <div class="slider-container">
                            <div class="slider-icon"><i class="fas fa-turtle"></i></div>
                            <input type="range" id="rateSlider" min="0.1" max="4" step="0.1" value="1">
                            <div class="slider-icon"><i class="fas fa-rabbit"></i></div>
                        </div>
                    </div>

                    <div class="control-group">
                        <div class="control-label">
                            <span>Pitch (Tone)</span>
                            <span class="control-value" id="pitchValue">1.0</span>
                        </div>
                        <div class="slider-container">
                            <div class="slider-icon"><i class="fas fa-volume-down"></i></div>
                            <input type="range" id="pitchSlider" min="0.1" max="2" step="0.1" value="1">
                            <div class="slider-icon"><i class="fas fa-volume-up"></i></div>
                        </div>
                    </div>

                    <div class="control-group">
                        <div class="control-label">
                            <span>Volume</span>
                            <span class="control-value" id="volumeValue">1.0</span>
                        </div>
                        <div class="slider-container">
                            <div class="slider-icon"><i class="fas fa-volume-off"></i></div>
                            <input type="range" id="volumeSlider" min="0" max="1" step="0.1" value="1">
                            <div class="slider-icon"><i class="fas fa-volume-up"></i></div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script>
        // Initialize all DOM element references
        function initDOMReferences() {
            return {
                textInput: document.getElementById('textInput'),
                charCount: document.getElementById('charCount'),
                voiceSelect: document.getElementById('voiceSelect'),
                rateSlider: document.getElementById('rateSlider'),
                rateValue: document.getElementById('rateValue'),
                pitchSlider: document.getElementById('pitchSlider'),
                pitchValue: document.getElementById('pitchValue'),
                volumeSlider: document.getElementById('volumeSlider'),
                volumeValue: document.getElementById('volumeValue'),
                speakBtn: document.getElementById('speakBtn'),
                pauseBtn: document.getElementById('pauseBtn'),
                resumeBtn: document.getElementById('resumeBtn'),
                stopBtn: document.getElementById('stopBtn'),
                statusDot: document.getElementById('statusDot'),
                statusText: document.getElementById('statusText'),
                languageMatch: document.getElementById('languageMatch'),
                selectedVoiceInfo: document.getElementById('selectedVoiceInfo'),
                voiceLanguage: document.getElementById('voiceLanguage'),
                voiceType: document.getElementById('voiceType'),
                voiceLocal: document.getElementById('voiceLocal'),
                langButtons: document.querySelectorAll('.lang-btn')
            };
        }

        // SpeechSynthesis Controller
        const synth = window.speechSynthesis;

        // Get DOM references
        const dom = initDOMReferences();

        // State variables
        let voices = [];
        let currentUtterance = null;
        let isSpeaking = false;
        let isPaused = false;
        let languageMap = {};
        let selectedVoice = null;

        // Initialize character count
        if (dom.charCount) {
            dom.charCount.textContent = dom.textInput.value.length;
        }

        // Language code to name mapping
        function initLanguageMap() {
            languageMap = {
                'af': 'Afrikaans',
                'am': 'Amharic',
                'ar': 'Arabic',
                'as': 'Assamese',
                'az': 'Azerbaijani',
                'be': 'Belarusian',
                'bg': 'Bulgarian',
                'bn': 'Bengali',
                'bo': 'Tibetan',
                'bs': 'Bosnian',
                'ca': 'Catalan',
                'cs': 'Czech',
                'cy': 'Welsh',
                'da': 'Danish',
                'de': 'German',
                'el': 'Greek',
                'en': 'English',
                'es': 'Spanish',
                'et': 'Estonian',
                'eu': 'Basque',
                'fa': 'Persian',
                'fi': 'Finnish',
                'fil': 'Filipino',
                'fr': 'French',
                'ga': 'Irish',
                'gl': 'Galician',
                'gu': 'Gujarati',
                'he': 'Hebrew',
                'hi': 'Hindi',
                'hr': 'Croatian',
                'hu': 'Hungarian',
                'hy': 'Armenian',
                'id': 'Indonesian',
                'is': 'Icelandic',
                'it': 'Italian',
                'ja': 'Japanese',
                'jv': 'Javanese',
                'ka': 'Georgian',
                'kk': 'Kazakh',
                'km': 'Khmer',
                'kn': 'Kannada',
                'ko': 'Korean',
                'lo': 'Lao',
                'lt': 'Lithuanian',
                'lv': 'Latvian',
                'mk': 'Macedonian',
                'ml': 'Malayalam',
                'mn': 'Mongolian',
                'mr': 'Marathi',
                'ms': 'Malay',
                'mt': 'Maltese',
                'my': 'Burmese',
                'ne': 'Nepali',
                'nl': 'Dutch',
                'no': 'Norwegian',
                'or': 'Odia',
                'pa': 'Punjabi',
                'pl': 'Polish',
                'ps': 'Pashto',
                'pt': 'Portuguese',
                'ro': 'Romanian',
                'ru': 'Russian',
                'sa': 'Sanskrit',
                'sd': 'Sindhi',
                'si': 'Sinhala',
                'sk': 'Slovak',
                'sl': 'Slovenian',
                'so': 'Somali',
                'sq': 'Albanian',
                'sr': 'Serbian',
                'su': 'Sundanese',
                'sv': 'Swedish',
                'sw': 'Swahili',
                'ta': 'Tamil',
                'te': 'Telugu',
                'th': 'Thai',
                'ti': 'Tigrinya',
                'tr': 'Turkish',
                'uk': 'Ukrainian',
                'ur': 'Urdu',
                'uz': 'Uzbek',
                'vi': 'Vietnamese',
                'zh': 'Chinese',
                'zu': 'Zulu'
            };
        }

        // Get language name from code
        function getLanguageName(langCode) {
            const baseCode = langCode.split('-')[0].toLowerCase();
            return languageMap[baseCode] || languageMap[langCode] || langCode;
        }

        // Simple language detection
        function detectTextLanguage(text) {
            const languagePatterns = {
                'en': /\b(the|and|that|have|for|not|with|you|this|but)\b/i,
                'es': /\b(el|la|que|y|en|los|se|del|las|un)\b/i,
                'fr': /\b(le|la|et|les|des|un|une|est|dans|pour)\b/i,
                'de': /\b(der|die|das|und|in|den|von|zu|mit|sich)\b/i,
                'it': /\b(il|la|e|di|che|in|una|per|con|non)\b/i,
                'pt': /\b(o|a|e|do|da|em|um|uma|para|com)\b/i,
                'ru': /[а-яА-Я]/,
                'zh': /[\u4e00-\u9fff]/,
                'ja': /[\u3040-\u309f\u30a0-\u30ff]/,
                'ko': /[\uac00-\ud7af]/,
                'ar': /[\u0600-\u06ff]/
            };

            for (const [lang, pattern] of Object.entries(languagePatterns)) {
                if (pattern.test(text)) {
                    return getLanguageName(lang);
                }
            }

            return 'Unknown / Mixed';
        }

        // Check if text language matches voice language
        function checkLanguageMatch(text, voiceLang) {
            const textLang = detectTextLanguage(text);
            const voiceBaseLang = voiceLang.split('-')[0].toLowerCase();

            let textBaseLang = 'unknown';
            for (const [code, name] of Object.entries(languageMap)) {
                if (name === textLang) {
                    textBaseLang = code;
                    break;
                }
            }

            if (textLang === 'Unknown / Mixed') {
                return {
                    match: 'unknown',
                    message: 'Text language could not be detected',
                    textLang: textLang,
                    voiceLang: getLanguageName(voiceBaseLang)
                };
            }

            if (textBaseLang === voiceBaseLang) {
                return {
                    match: 'good',
                    message: `Text (${textLang}) matches voice language`,
                    textLang: textLang,
                    voiceLang: getLanguageName(voiceBaseLang)
                };
            } else {
                return {
                    match: 'bad',
                    message: `Text (${textLang}) may not match voice language (${getLanguageName(voiceBaseLang)})`,
                    textLang: textLang,
                    voiceLang: getLanguageName(voiceBaseLang)
                };
            }
        }

        // Update language match display
        function updateLanguageMatch() {
            if (!selectedVoice) {
                if (dom.languageMatch) {
                    dom.languageMatch.innerHTML = '<i class="fas fa-exclamation-circle language-match-icon"></i><span>Select a voice to check language compatibility</span>';
                }
                return;
            }

            const text = dom.textInput.value;
            const match = checkLanguageMatch(text, selectedVoice.lang);

            let icon = 'fa-exclamation-circle';
            if (match.match === 'good') icon = 'fa-check-circle';
            if (match.match === 'bad') icon = 'fa-times-circle';

            if (dom.languageMatch) {
                dom.languageMatch.innerHTML = `<i class="fas ${icon} language-match-icon"></i><span>${match.message}</span>`;
            }
        }

        // Update character count and language match
        if (dom.textInput) {
            dom.textInput.addEventListener('input', () => {
                if (dom.charCount) {
                    dom.charCount.textContent = dom.textInput.value.length;
                }
                updateLanguageMatch();
            });
        }

        // Load available voices
        function loadVoices() {
            voices = synth.getVoices();
            if (!dom.voiceSelect) return;

            dom.voiceSelect.innerHTML = '';

            const defaultOption = document.createElement('option');
            defaultOption.textContent = 'Select a voice (language/accent)';
            defaultOption.value = '';
            dom.voiceSelect.appendChild(defaultOption);

            const voicesByLanguage = {};

            voices.forEach(voice => {
                const lang = getLanguageName(voice.lang);
                if (!voicesByLanguage[lang]) {
                    voicesByLanguage[lang] = [];
                }
                voicesByLanguage[lang].push(voice);
            });

            const sortedLanguages = Object.keys(voicesByLanguage).sort();

            sortedLanguages.forEach(language => {
                const groupHeader = document.createElement('optgroup');
                groupHeader.label = `${language} Voices`;

                voicesByLanguage[language].forEach(voice => {
                    const option = document.createElement('option');
                    let voiceName = voice.name;

                    voiceName = voiceName.replace(/Microsoft |Google |Apple /g, '');
                    voiceName = voiceName.replace(new RegExp(`\\(${language}\\)`, 'i'), '').trim();
                    voiceName = voiceName.replace(new RegExp(`- ${language}`, 'i'), '').trim();

                    option.textContent = voiceName;
                    option.value = voice.name;

                    if (voice.default) {
                        option.textContent += ' (Default)';
                        option.selected = true;
                        selectedVoice = voice;
                        updateSelectedVoiceInfo();
                        updateLanguageMatch();
                    }

                    option.setAttribute('data-lang', voice.lang);
                    option.setAttribute('data-name', voice.name);
                    option.setAttribute('data-voice', JSON.stringify({
                        name: voice.name,
                        lang: voice.lang,
                        localService: voice.localService,
                        default: voice.default
                    }));

                    groupHeader.appendChild(option);
                });

                dom.voiceSelect.appendChild(groupHeader);
            });

            if (!selectedVoice && voices.length > 0) {
                selectedVoice = voices[0];
                dom.voiceSelect.value = selectedVoice.name;
                updateSelectedVoiceInfo();
                updateLanguageMatch();
            }
        }

        // Update selected voice info panel
        function updateSelectedVoiceInfo() {
            if (!selectedVoice) return;

            if (dom.selectedVoiceInfo) {
                dom.selectedVoiceInfo.style.display = 'flex';
            }
            if (dom.voiceLanguage) {
                dom.voiceLanguage.textContent = getLanguageName(selectedVoice.lang);
            }

            let type = 'Standard';
            const name = selectedVoice.name.toLowerCase();
            if (name.includes('female') || name.includes('woman')) type = 'Female';
            if (name.includes('male') || name.includes('man')) type = 'Male';
            if (name.includes('natural') || name.includes('neural')) type = 'Neural/Enhanced';

            if (dom.voiceType) {
                dom.voiceType.textContent = type;
            }
            if (dom.voiceLocal) {
                dom.voiceLocal.textContent = selectedVoice.localService ? 'Yes (Local)' : 'No (Cloud/Network)';
            }
        }

        // Load voices when they become available
        if (speechSynthesis.onvoiceschanged !== undefined) {
            speechSynthesis.onvoiceschanged = loadVoices;
        }

        // Initialize language map and voices
        initLanguageMap();
        loadVoices();

        // Voice selection change handler
        if (dom.voiceSelect) {
            dom.voiceSelect.addEventListener('change', () => {
                const selectedOption = dom.voiceSelect.options[dom.voiceSelect.selectedIndex];
                if (selectedOption.value) {
                    const voiceData = JSON.parse(selectedOption.getAttribute('data-voice'));
                    selectedVoice = voices.find(v => v.name === voiceData.name);
                    updateSelectedVoiceInfo();
                    updateLanguageMatch();

                    if (currentUtterance && isSpeaking && !isPaused) {
                        synth.cancel();
                        speakText();
                    }
                } else {
                    selectedVoice = null;
                    if (dom.selectedVoiceInfo) {
                        dom.selectedVoiceInfo.style.display = 'none';
                    }
                    updateLanguageMatch();
                }
            });
        }

        // Update slider value displays
        if (dom.rateSlider && dom.rateValue) {
            dom.rateSlider.addEventListener('input', () => {
                const value = parseFloat(dom.rateSlider.value);
                dom.rateValue.textContent = value.toFixed(1);

                if (currentUtterance && isSpeaking) {
                    currentUtterance.rate = value;
                }
            });
        }

        if (dom.pitchSlider && dom.pitchValue) {
            dom.pitchSlider.addEventListener('input', () => {
                const value = parseFloat(dom.pitchSlider.value);
                dom.pitchValue.textContent = value.toFixed(1);

                if (currentUtterance && isSpeaking) {
                    currentUtterance.pitch = value;
                }
            });
        }

        if (dom.volumeSlider && dom.volumeValue) {
            dom.volumeSlider.addEventListener('input', () => {
                const value = parseFloat(dom.volumeSlider.value);
                dom.volumeValue.textContent = value.toFixed(1);

                if (currentUtterance && isSpeaking) {
                    currentUtterance.volume = value;
                }
            });
        }

        // Language example buttons
        const languageExamples = {
            'en': 'Welcome to the Speech Synthesis demonstration. This tool converts text to speech using different voices and accents. Remember: this is NOT a translation tool.',
            'es': 'Bienvenido a la demostración de Síntesis de Voz. Esta herramienta convierte texto en voz usando diferentes voces y acentos. Recuerda: esto NO es una herramienta de traducción.',
            'fr': 'Bienvenue à la démonstration de Synthèse Vocale. Cet outil convertit el texto en parole en utilisant différentes voix et accents. Rappelez-vous: ceci N\'EST PAS un outil de traduction.',
            'de': 'Willkommen zur Sprachsynthese-Demonstration. Dieses Tool wandelt Text in Sprache um und verwendet dabei verschiedene Stimmen und Akzente. Denken Sie daran: Dies ist KEIN Übersetzungstool.',
            'it': 'Benvenuto alla dimostrazione di Sintesi Vocale. Questo strumento converte il texto in voce utilizzando diverse voci e accenti. Ricorda: questo NON è uno strumento di traducción.',
            'ja': '音声合成デモンストレーションへようこそ。このツールは、さまざまな声とアクセントを使用してテキストを音声に変換します。注意:これは翻訳ツールではありません。',
            'zh': '欢迎使用语音合成演示。此工具使用不同的语音和口音将文本转换为语音。请记住:这不是翻译工具。'
        };

        if (dom.langButtons) {
            dom.langButtons.forEach(btn => {
                btn.addEventListener('click', () => {
                    const lang = btn.getAttribute('data-lang');
                    if (dom.textInput) {
                        dom.textInput.value = languageExamples[lang] || languageExamples['en'];
                        if (dom.charCount) {
                            dom.charCount.textContent = dom.textInput.value.length;
                        }
                        updateLanguageMatch();
                    }
                });
            });
        }

        // Update status indicator
        function updateStatus(text) {
            if (dom.statusText) {
                dom.statusText.textContent = text;
            }
        }

        // Update button states
        function updateButtonStates(speakEnabled, pauseEnabled, resumeEnabled, stopEnabled) {
            if (dom.speakBtn) dom.speakBtn.disabled = !speakEnabled;
            if (dom.pauseBtn) dom.pauseBtn.disabled = !pauseEnabled;
            if (dom.resumeBtn) dom.resumeBtn.disabled = !resumeEnabled;
            if (dom.stopBtn) dom.stopBtn.disabled = !stopEnabled;
        }

        // Speak function - Play button action
        function speakText() {
            // Stop any current speech
            if (synth.speaking) {
                synth.cancel();
            }

            if (!dom.textInput) return;

            const text = dom.textInput.value.trim();

            if (text === '') {
                updateStatus('Please enter text to play');
                return;
            }

            if (!selectedVoice) {
                updateStatus('Please select a voice first');
                return;
            }

            currentUtterance = new SpeechSynthesisUtterance(text);

            currentUtterance.rate = parseFloat(dom.rateSlider?.value || 1);
            currentUtterance.pitch = parseFloat(dom.pitchSlider?.value || 1);
            currentUtterance.volume = parseFloat(dom.volumeSlider?.value || 1);
            currentUtterance.voice = selectedVoice;

            // Event handlers
            currentUtterance.onstart = () => {
                isSpeaking = true;
                isPaused = false;
                updateStatus('Playing');
                updateButtonStates(false, true, false, true);
                if (dom.statusDot) {
                    dom.statusDot.classList.add('active');
                }
            };

            currentUtterance.onend = () => {
                isSpeaking = false;
                isPaused = false;
                updateStatus('Finished');
                updateButtonStates(true, false, false, false);
                if (dom.statusDot) {
                    dom.statusDot.classList.remove('active');
                }
            };

            currentUtterance.onerror = (event) => {
                isSpeaking = false;
                isPaused = false;
                updateStatus(`Error: ${event.error}`);
                updateButtonStates(true, false, false, false);
                if (dom.statusDot) {
                    dom.statusDot.classList.remove('active');
                }
            };

            currentUtterance.onpause = () => {
                isPaused = true;
                updateStatus('Paused');
                updateButtonStates(true, false, true, true);
            };

            currentUtterance.onresume = () => {
                isPaused = false;
                updateStatus('Playing');
                updateButtonStates(false, true, false, true);
            };

            // Start speaking
            synth.speak(currentUtterance);
            updateStatus('Starting...');
        }

        // Pause speech
        function pauseSpeech() {
            if (synth.speaking && !synth.paused) {
                synth.pause();
                isPaused = true;
                updateStatus('Paused');
                updateButtonStates(true, false, true, true);
            }
        }

        // Resume speech
        function resumeSpeech() {
            if (synth.speaking && synth.paused) {
                synth.resume();
                isPaused = false;
                updateStatus('Playing');
                updateButtonStates(false, true, false, true);
            }
        }

        // Stop speech
        function stopSpeech() {
            if (synth.speaking || isSpeaking) {
                synth.cancel();
                isSpeaking = false;
                isPaused = false;
                updateStatus('Stopped');
                updateButtonStates(true, false, false, false);
                if (dom.statusDot) {
                    dom.statusDot.classList.remove('active');
                }
                currentUtterance = null;
            }
        }

        // Event listeners for buttons
        if (dom.speakBtn) {
            dom.speakBtn.addEventListener('click', speakText);
            dom.speakBtn.addEventListener('touchend', (e) => {
                e.preventDefault();
                if (!dom.speakBtn.disabled) speakText();
            });
        }

        if (dom.pauseBtn) {
            dom.pauseBtn.addEventListener('click', pauseSpeech);
            dom.pauseBtn.addEventListener('touchend', (e) => {
                e.preventDefault();
                if (!dom.pauseBtn.disabled) pauseSpeech();
            });
        }

        if (dom.resumeBtn) {
            dom.resumeBtn.addEventListener('click', resumeSpeech);
            dom.resumeBtn.addEventListener('touchend', (e) => {
                e.preventDefault();
                if (!dom.resumeBtn.disabled) resumeSpeech();
            });
        }

        if (dom.stopBtn) {
            dom.stopBtn.addEventListener('click', stopSpeech);
            dom.stopBtn.addEventListener('touchend', (e) => {
                e.preventDefault();
                if (!dom.stopBtn.disabled) stopSpeech();
            });
        }

        // Keyboard shortcuts
        document.addEventListener('keydown', (event) => {
            // Space bar to play/pause
            if (event.key === ' ' && event.target.tagName !== 'TEXTAREA' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'SELECT') {
                event.preventDefault();
                if (synth.speaking && !synth.paused) {
                    pauseSpeech();
                } else if (synth.speaking && synth.paused) {
                    resumeSpeech();
                } else {
                    speakText();
                }
            }

            // Ctrl/Cmd + Enter to play
            if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
                event.preventDefault();
                speakText();
            }

            // Escape to stop
            if (event.key === 'Escape') {
                stopSpeech();
            }
        });

        // Initialize on load
        document.addEventListener('DOMContentLoaded', () => {
            updateStatus('Ready');
            updateButtonStates(true, false, false, false);

            if (dom.rateValue) {
                dom.rateValue.textContent = dom.rateSlider?.value || '1.0';
            }
            if (dom.pitchValue) {
                dom.pitchValue.textContent = dom.pitchSlider?.value || '1.0';
            }
            if (dom.volumeValue) {
                dom.volumeValue.textContent = dom.volumeSlider?.value || '1.0';
            }

            updateLanguageMatch();

            if (!('speechSynthesis' in window)) {
                updateStatus('Speech Synthesis not supported');
                alert('Your browser does not support the Speech Synthesis API. Please try Chrome, Edge, or Safari.');
            }
        });

        // State monitoring function
        function monitorSpeechState() {
            if (synth.speaking) {
                if (synth.paused) {
                    if (!isPaused) {
                        isPaused = true;
                        updateStatus('Paused');
                        updateButtonStates(true, false, true, true);
                    }
                } else {
                    if (!isSpeaking || isPaused) {
                        isSpeaking = true;
                        isPaused = false;
                        updateStatus('Playing');
                        updateButtonStates(false, true, false, true);
                    }
                }
            } else {
                if (isSpeaking) {
                    isSpeaking = false;
                    isPaused = false;
                    updateStatus('Ready');
                    updateButtonStates(true, false, false, false);
                    if (dom.statusDot) {
                        dom.statusDot.classList.remove('active');
                    }
                }
            }
        }

        // Monitor speech state regularly
        setInterval(monitorSpeechState, 100);
    </script>
    <script>
        (function syncTheme() {
            try {
                if (window.parent && window.parent !== window) {
                    const parentHtml = window.parent.document.documentElement;
                    const localHtml = document.documentElement;
                    
                    const updateTheme = () => {
                        if (parentHtml.classList.contains('dark')) {
                            localHtml.classList.add('dark');
                        } else {
                            localHtml.classList.remove('dark');
                        }
                    };
                    
                    updateTheme();
                    
                    const observer = new MutationObserver((mutations) => {
                        mutations.forEach((mutation) => {
                            if (mutation.attributeName === 'class') {
                                updateTheme();
                            }
                        });
                    });
                    observer.observe(parentHtml, { attributes: true });
                    return;
                }
            } catch (e) {
                console.warn('Could not sync theme with parent window.', e);
            }
            
            if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
                document.documentElement.classList.add('dark');
            }
        })();
    </script>
</body>
</html>
MikeHiggins.me.uk LinkedIn GitHub Bluesky Contra Upwork
© 2026 Mike Higgins. All rights reserved.