🎙️ Voice → Serial Bluetooth Tutorial

This tutorial shows how to build a multilingual voice command page inside SoifGo WebView that converts your speech to text and sends it directly to a connected Bluetooth serial device (HC-05, HC-06, ESP32…).

📸 Preview

SoifGo Voice Control page screenshot

Language selector + Start button + live recognized text

✅ How It Works — Three Golden Principles

Memorize these three principles — with them you can build any voice-controlled Bluetooth project in SoifGo.

1️⃣ Request Microphone Permission (First Time Only)

On the first run, SoifGo must ask the user for microphone permission. Call this Sub once — the app will show a native permission dialog. After the user allows it, the permission stays granted for all future sessions.

🔹 The SoifGo Sub (native side):
Sub request_mic_perm(PermissionRequest1 As Object)
    ' SoifGo automatically shows the native microphone permission dialog.
    ' The user's choice is remembered by Android.
End Sub
🔹 The full HTML file that calls this Sub (serial_voice.html):

This is the complete page. When the user taps the microphone button, the script first calls request_mic_perm and only then starts speech recognition.

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SoifGo Voice · Multilingual</title>
    <style>
        * { box-sizing: border-box; }
        body {
            font-family: Tahoma, sans-serif;
            text-align: center;
            padding: 20px;
            background-color: #121212;
            color: white;
            margin: 0;
            min-height: 100vh;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
        }
        .container {
            max-width: 650px;
            width: 100%;
            background: #1a1a1a;
            border-radius: 28px;
            padding: 25px 20px 30px;
            box-shadow: 0 12px 25px rgba(0, 0, 0, 0.7);
        }
        h2 {
            margin-top: 0;
            font-weight: 500;
            letter-spacing: 0.5px;
            color: #e0e0e0;
            font-size: 1.8rem;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }
        .lang-selector {
            margin-bottom: 25px;
            text-align: left;
            background: #252525;
            padding: 12px 18px;
            border-radius: 60px;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            gap: 12px;
            flex-wrap: wrap;
            width: 100%;
        }
        .lang-selector label {
            font-size: 1rem;
            color: #b0b0b0;
            font-weight: 400;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        select {
            background: #2e2e2e;
            color: #f0f0f0;
            border: 1px solid #3e3e3e;
            border-radius: 40px;
            padding: 12px 22px;
            font-size: 1rem;
            font-family: inherit;
            font-weight: 500;
            cursor: pointer;
            outline: none;
            flex: 1;
            min-width: 180px;
            max-width: 320px;
        }
        button {
            padding: 16px 36px;
            font-size: 1.2rem;
            cursor: pointer;
            background: #2196f3;
            color: white;
            border: none;
            border-radius: 50px;
            font-weight: bold;
            margin-bottom: 18px;
            width: 100%;
            max-width: 320px;
        }
        button.listening {
            background: #dc3545;
            animation: pulse 1.5s infinite;
        }
        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.85; }
            100% { opacity: 1; }
        }
        #output {
            margin-top: 12px;
            font-size: 1.2rem;
            color: #90caf9;
            font-weight: 500;
            padding: 18px 20px;
            border: 2px dashed #2196f3;
            background: #1e1e1e;
            border-radius: 18px;
            min-height: 70px;
            word-break: break-word;
        }
        #status {
            font-size: 0.9rem;
            color: #aaa;
            font-style: italic;
            margin: 12px 0 5px;
            min-height: 1.8rem;
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>🎙️ SoifGo Voice Control</h2>

        <div class="lang-selector">
            <label for="lang-select">🌐 Language:</label>
            <select id="lang-select">
                <option value="en-US" selected>English</option>
                <option value="es-ES">Español</option>
                <option value="zh-CN">简体中文</option>
                <option value="fr-FR">Français</option>
                <option value="de-DE">Deutsch</option>
                <option value="ja-JP">日本語</option>
                <option value="ru-RU">Русский</option>
                <option value="ar-SA">العربية</option>
                <option value="fa-IR">فارسی</option>
                <option value="it-IT">Italiano</option>
                <option value="hi-IN">हिन्दी</option>
                <option value="tr-TR">Türkçe</option>
                <option value="ko-KR">한국어</option>
                <option value="pt-BR">Português</option>
                <option value="he-IL">עברית</option>
                <option value="ku-IQ">کوردی</option>
                <option value="ur-PK">اردو</option>
                <option value="bn-BD">বাংলা</option>
                <option value="id-ID">Bahasa Indonesia</option>
                <option value="ta-IN">தமிழ்</option>
                <option value="th-TH">ไทย</option>
                <option value="sw-KE">Swahili</option>
            </select>
        </div>

        <p id="status">Status: Ready for voice command</p>
        <button id="start-btn">🎤 Start Speaking</button>

        <div id="output">Your voice command will appear here...</div>
    </div>

    <script>
        (function() {
            const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
            const startBtn = document.getElementById('start-btn');
            const output = document.getElementById('output');
            const status = document.getElementById('status');
            const langSelect = document.getElementById('lang-select');

            let isListening = false;
            let recognition = null;

            if (!SpeechRecognition) {
                output.innerText = "⚠️ Speech recognition not supported.";
                startBtn.disabled = true;
                return;
            }

            recognition = new SpeechRecognition();
            recognition.lang = 'en-US';
            recognition.continuous = true;
            recognition.interimResults = false;
            recognition.maxAlternatives = 1;

            recognition.onresult = (event) => {
                const speechToText = event.results[event.resultIndex][0].transcript;
                if (speechToText.trim() !== "") {
                    output.innerText = speechToText;
                    if (window.soifgo && typeof window.soifgo.CallSub === 'function') {
                        window.soifgo.CallSub('html_bluetooth_rx', true, speechToText);
                        status.innerText = "✅ Sent: " + speechToText;
                    } else {
                        status.innerText = "📤 (simulated) " + speechToText;
                    }
                }
            };

            recognition.onerror = (event) => {
                if (event.error === 'no-speech') {
                    status.innerText = "🎤 No speech detected, still waiting...";
                } else if (event.error === 'not-allowed') {
                    status.innerText = "🚫 Microphone denied.";
                } else {
                    status.innerText = "⚠️ Error: " + event.error;
                }
            };

            recognition.onend = () => {
                if (isListening) {
                    try { recognition.start(); } catch (e) {}
                } else {
                    status.innerText = "Status: Stopped.";
                }
            };

            startBtn.addEventListener('click', () => {
                if (!isListening) {
                    // Request mic permission first
                    if (window.soifgo && typeof window.soifgo.CallSub === 'function') {
                        window.soifgo.CallSub('request_mic_perm', true, null);
                    }
                    isListening = true;
                    startBtn.innerText = "🛑 Stop Listening";
                    startBtn.classList.add('listening');
                    status.innerText = "🎧 Listening...";
                    output.innerText = "";
                    recognition.lang = langSelect.value;
                    try { recognition.start(); } catch (e) {}
                } else {
                    isListening = false;
                    startBtn.innerText = "🎤 Start Speaking";
                    startBtn.classList.remove('listening');
                    recognition.stop();
                    status.innerText = "Status: Stopped.";
                }
            });

            langSelect.addEventListener('change', (e) => {
                const newLang = e.target.value;
                if (recognition) {
                    if (isListening) {
                        recognition.stop();
                        recognition.lang = newLang;
                    } else {
                        recognition.lang = newLang;
                    }
                }
            });
        })();
    </script>
</body>
</html>
Note: This permission is needed only once. After the user taps Allow, the browser's SpeechRecognition API works without further prompts. The Sub is still called on every microphone button press for safety, but the dialog will not appear again.
⚠️ Important — where is request_mic_perm called? In this HTML file, request_mic_perm is called inside the startBtn.addEventListener('click', …) handler. If you want SoifGo to actually show the permission dialog, you must add this single line at the beginning of that handler:
window.soifgo.CallSub('request_mic_perm', true, null);
The current example already works, but adding this call makes the permission request explicit and prevents the "not-allowed" error on the first run.

2️⃣ Send Recognized Text to Bluetooth

After speech recognition returns a transcript, JavaScript calls SoifGo's Bluetooth-send Sub with one line:

window.soifgo.CallSub('html_bluetooth_rx', true, speechToText);

The full flow is:

  1. User taps the microphone button → request_mic_perm is triggered.
  2. User speaks → the browser's SpeechRecognition converts voice to text.
  3. JavaScript calls window.soifgo.CallSub('html_bluetooth_rx', true, txt).
  4. SoifGo receives the text in its html_bluetooth_rx() Sub.
  5. SoifGo forwards the text to the connected Bluetooth device.
🔹 JavaScript side (inside your HTML page):
recognition.onresult = (event) => {
  const speechToText = event.results[event.resultIndex][0].transcript;
  if (speechToText.trim() !== "") {
    output.innerText = speechToText;
    window.soifgo.CallSub('html_bluetooth_rx', true, speechToText);
  }
};
🔹 SoifGo side (native Sub):
Sub html_bluetooth_rx(txt As String)
    ' Send the text to the connected Bluetooth serial device.
    BluetoothSend(txt)
End Sub

3️⃣ Receive Bluetooth Data (Optional)

If your hardware sends data back (e.g. sensor values, ACK messages), define a global JavaScript function named html_bluetooth_tx. SoifGo calls it automatically whenever Bluetooth data arrives:

function html_bluetooth_tx(data) {
  document.getElementById('output').innerText = data;
}
window.html_bluetooth_tx = html_bluetooth_tx;  // register globally

🚀 Step-by-Step Setup in SoifGo

1 Create a button with HTML behavior
In SoifGo's visual editor, add a button and set its behavior to HTML (offline).
2 Pick the example file
From the built-in sample list, choose serial_voice.html. It is already available inside the SoifGo library — no download needed.
3 Go to the Bluetooth page and connect
Pair and connect to your HC-05 / HC-06 / ESP32 Bluetooth module. Wait for the Connected status.
4 Return to the voice page and press the microphone button
The first time, request_mic_perm is called and Android asks for microphone permission — tap Allow. Next times, no prompt appears.
5 Speak your command
The recognized text appears on screen and is sent instantly to the Bluetooth serial terminal of your device.
Tip: Use any serial terminal on your PC or phone (or the SoifGo Bluetooth terminal) to see the incoming voice commands in real time.

🌐 Supported Languages

The page includes a language selector with 22 languages. Changing the language restarts speech recognition with the new locale automatically:

English · Español · 简体中文 · Français · Deutsch · 日本語 · Русский · العربية · فارسی · Italiano · हिन्दी · Türkçe · 한국어 · Português · עברית · کوردی · اردو · বাংলা · Bahasa Indonesia · தமிழ் · ไทย · Swahili

📌 Important Notes

💡 Arduino Example — Voice-Controlled LED

This simple sketch receives text over Bluetooth serial and turns the built-in LED (pin 13) on or off. It is the fastest way to confirm that your voice commands are actually reaching the hardware.

🔹 Arduino sketch (works on Uno, Nano, Mega, and most ESP boards):
/*
 * SoifGo Voice -> Serial Bluetooth -> LED
 * ----------------------------------------
 * Listens for the phrases "LAMP ON" and "LAMP OFF"
 * and controls the built-in LED on pin 13.
 *
 * Wiring:
 *   Arduino pin 13    ->  built-in LED (no external parts needed)
 *   HC-05 / HC-06 RX  ->  Arduino TX (pin 1)  [via voltage divider if 5V board]
 *   HC-05 / HC-06 TX  ->  Arduino RX (pin 0)
 *   HC-05 VCC -> 5V, GND -> GND
 *
 * Baud rate: 9600
 */

const int LED_PIN = 13;
String incoming = "";

void setup() {
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  Serial.begin(9600);
  Serial.println("Ready. Waiting for voice commands...");
}

void loop() {
  while (Serial.available() > 0) {
    char c = Serial.read();
    if (c == '\n' || c == '\r') {
      if (incoming.length() > 0) {
        handleCommand(incoming);
        incoming = "";
      }
    } else {
      incoming += c;
    }
  }
}

void handleCommand(String cmd) {
  cmd.trim();
  cmd.toUpperCase();
  Serial.print("Received: ");
  Serial.println(cmd);
  if (cmd == "LAMP ON" || cmd == "LED ON" || cmd == "ON") {
    digitalWrite(LED_PIN, HIGH);
    Serial.println("-> LED is now ON");
  }
  else if (cmd == "LAMP OFF" || cmd == "LED OFF" || cmd == "OFF") {
    digitalWrite(LED_PIN, LOW);
    Serial.println("-> LED is now OFF");
  }
  else {
    Serial.println("-> Unknown command.");
  }
}
✅ How to test:
  1. Upload the sketch to your Arduino.
  2. Open the Serial Monitor at 9600 baud — you should see "Ready...".
  3. Connect your HC-05 / HC-06 to the SoifGo app.
  4. Open the serial_voice.html page and tap the microphone.
  5. Say clearly: "LAMP ON" — the LED on pin 13 lights up.
  6. Say: "LAMP OFF" — the LED turns off.
💡 Tip for non-native English speakers: If the recognizer has trouble with the word "LAMP", you can use shorter commands. Replace "LAMP ON" with "ON" and "LAMP OFF" with "OFF" in both the Arduino sketch and your speech. The sketch above already accepts "ON" and "OFF" as valid alternatives.
⚠️ Warning — voltage levels: HC-05 and HC-06 modules are 3.3 V devices. If you use a 5 V Arduino (Uno, Nano, Mega), place a voltage divider (e.g. 1 kΩ + 2 kΩ) between the Arduino TX and the module RX to avoid damaging it. The module TX can go directly to the Arduino RX. On 3.3 V boards (ESP32, ESP8266, Arduino Due) you can connect them directly.
✅ For ESP32 / ESP8266 users: Change LED_PIN to 2 (the built-in LED on most ESP32 dev boards) or to LED_BUILTIN. The rest of the sketch works unchanged.

🎯 Summary — The Three Golden Rules

  1. Ask once: Call request_mic_perm for microphone access.
  2. Send with one line: window.soifgo.CallSub('html_bluetooth_rx', true, txt).
  3. Receive with one function: define global html_bluetooth_tx(data).