Skip to Content

Tutor de Línguas AI com UNIHIKER M10

Tutor de Línguas AI com UNIHIKER M10

Neste tutorial, vais aprender a construir o teu próprio Tutor de Línguas com IA num UNIHIKER M10 usando os modelos de IA da OpenAI. Vais precisar de uma conta OpenAI e de uma chave API para isso.

O UNIHIKER M10 é uma pequena placa com 512 MB de RAM e 16 GB de armazenamento eMMC, e um ecrã tátil de 2,8 polegadas. Para além de Wi-Fi e conectividade Bluetooth, a placa inclui sensores integrados como sensor de luz, acelerómetro, giroscópio e, mais importante, um microfone.

Corre Debian Linux, suporta programação em Python e vem com muitas bibliotecas Python pré-instaladas. Isto facilita a implementação de soluções de IA, como um Tutor de Línguas com IA. O seguinte vídeo curto demonstra o Tutor de Línguas com IA que vamos construir.

Podes precisar de aumentar o volume no teu computador para ouvires a minha voz e a resposta da IA.

Peças Necessárias

Para este tutorial precisas de uma placa UNIHIKER M10. Podes adquiri-la na DFRobot ou na Amazon. Se tiveres uma coluna USB, podes usá-la e ligá-la ao UNIHIKER M10. Mas se quiseres adicionar as tuas próprias colunas pequenas, vais precisar de um amplificador PAM8403 e uma ou duas das colunas listadas abaixo.

Amplificador PAM8403

2 x Colunas 3 Watt 4 Ohm

Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.

Hardware do UNIHIKER M10

O UNIHIKER M10 é um computador de placa única compacto para educação, prototipagem e aplicações AIoT. Vem com um sistema baseado em Linux, sensores e interfaces de hardware numa só placa.

O dispositivo baseia-se num processador quad-core Arm Cortex-A35 a correr até 1,2 GHz. Inclui 512 MB de RAM e 16 GB de armazenamento eMMC e corre o sistema operativo Debian Linux.

Front and back of UNIHIKER M10
Frente e verso do UNIHIKER M10 (source)

A placa integra um ecrã tátil de 2,8 polegadas com resolução de 240 × 320 pixels, conectividade Wi-Fi e Bluetooth. Os sensores integrados incluem sensor de luz, acelerómetro, giroscópio e microfone. A expansão de hardware está disponível através de portas USB, conectores Gravity para sensores e um conector edge compatível com micro:bit que expõe interfaces GPIO, I2C, SPI e UART.

Para mais detalhes técnicos, vê o nosso Voice Assistant on UNIHIKER M10 with OpenAI tutorial. Este tutorial também te explica como programar o UNIHIKER, como instalar bibliotecas Python e como configurar o Wi-Fi. Tudo isso é necessário para correr o tutor de IA que vamos construir neste tutorial.

Ligação de Colunas ao UNIHIKER M10

O nosso Tutor de IA terá a capacidade de gerar voz. Para isso, vamos precisar de colunas. Podes ligar colunas ao UNIHIKER M10 via USB ou Bluetooth. Esta é a solução mais fácil e, se optares por ela, podes saltar esta secção.

Eu queria usar colunas mais pequenas para tornar o meu Tutor de IA portátil. O UNIHIKER M10 tem uma saída lineout, mas infelizmente sem conector. É preciso soldar fios a pads específicos na parte de trás da placa e também precisas de um pequeno amplificador para alimentar as colunas. Tirei a ligação do post AI Assistant with OpenAI GPT, Azure Speech API and UNIHIKER.

Amplificador PAM8403

Usei um módulo PAM8403 como amplificador. O módulo PAM8403 é um pequeno amplificador de áudio estéreo Classe-D baseado no chip PAM8403. Aceita uma tensão de alimentação de 2,5 V até 5,5 V. A 5V, e ao alimentar colunas de 4 Ω, cada canal pode produzir até cerca de 3 W de potência de saída. A imagem seguinte mostra o pinout do módulo amplificador PAM8403:

Pinout of PAM8403 Amplifier
Pinout do Amplificador PAM8403

Para mais informações sobre o módulo amplificador PAM8403 vê o nosso Audio with PAM8403, PCM5102 and ESP32 tutorial.

Saída Lineout do UNIHIKER M10

Como mencionado, não há conector para a saída lineout no UNIHIKER M10, mas podes aceder a ela através de pads (de teste) na parte de trás da placa (schematics). A imagem abaixo mostra a localização dos pads de Lineout e alimentação que precisamos para ligar o amplificador PAM8403:

Lineout and power supply pads on UNIHIKER M10
Pads de Lineout e alimentação no UNIHIKER M10

Medi 4,7 V no pad VCC, o que é estranho, pois esperava 3,3V ou 5V, mas o PAM8403 funciona bem com 4,7 V. Também a minha placa tinha apenas um pad para VCC, enquanto a foto acima mostra dois pads. Mas tudo funcionou bem, na mesma.

Ligação do Amplificador e Colunas ao UNIHIKER M10

Nesta secção vou mostrar-te como ligar o amplificador PAM8403 e as colunas ao UNIHIKER M10. As pequenas colunas de 3W costumam vir com fichas que terás de cortar, pois vamos soldar os fios das colunas diretamente ao módulo PAM8403:

A imagem seguinte mostra como ligar o UNIHIKER M10 ao amplificador PAM8403 e às colunas:

Soldar os fios aos pads do UNIHIKER M10 é fácil, mas certifica-te de ligar aos pads corretos e de não danificar nada no processo. A foto abaixo mostra a minha ligação concluída:

Nota que não precisas de duas colunas, pois som estéreo não é realmente necessário. Mas duas colunas serão mais altas do que uma. Se quiseres som realmente alto, usa uma coluna USB com fonte de alimentação externa.

Manual do Utilizador para o Tutor de Línguas com IA

Nesta secção vou explicar rapidamente como funciona a interface do utilizador do Tutor de Línguas com IA. Isto vai facilitar a compreensão do código na secção seguinte e o uso do software do Tutor. A imagem abaixo mostra a GUI com os elementos funcionais anotados:

Pressionas o botão A e manténs-no pressionado enquanto falas. Quando soltas o botão A, o áudio gravado é enviado para a OpenAI para transcrição e tradução. A transcrição e tradução são então exibidas no campo Resposta e a tradução é reproduzida em voz alta. Podes reproduzir novamente o áudio da tradução pressionando o botão B.

Podes selecionar a língua alvo para a tradução pressionando o botão “Select Language” no topo. Atualmente, alterna entre “Japanese”, “Italian” e “German” como línguas. Mas podes facilmente expandir para outras ou mais línguas no código.

Nota que, embora a interface do utilizador esteja em inglês, podes falar em qualquer língua suportada pelo modelo TTS da OpenAI (link). O sistema vai entender a língua (se for suportada) e traduzir para a língua alvo selecionada. Podes até falar na língua alvo para verificar a tua pronúncia. Vê os dois vídeos seguintes para uma demonstração, onde falo alemão e japonês:

Quando pressionas o botão “Explain” na parte inferior, uma explicação da gramática da tradução é exibida. De forma semelhante, se pressionares o botão “Example”, frases de exemplo com gramática semelhante são adicionadas. Os dois vídeos seguintes demonstram esta funcionalidade:

Finalmente, há botões para aumentar e diminuir o volume e o tamanho da fonte no topo do ecrã e botões de scroll na parte inferior.

Embora possas usar os dedos para controlar a UI, usar a pequena caneta que vem com o UNIHIKER M10 funciona melhor. Nota que podes até tocar no campo de resposta e arrastar/rolar em vez de usar os botões de scroll ou a barra de scroll.

Obter Chave API da OpenAI

O nosso Tutor de Línguas com IA vai usar modelos de IA fornecidos pela OpenAI. Por isso, vais precisar de uma conta OpenAI e de uma chave API. Vai a https://platform.openai.com e regista-te com um endereço de email ou uma conta Google ou Microsoft existente.

Depois de verificar o teu email e completar a configuração inicial, inicia sessão no painel da OpenAI, platform.openai.com/api-keys e encontra ou cria a tua Chave API (=SECRET KEY) como mostrado abaixo:

OpenAI API keys
Chaves API da OpenAI

A Chave API é uma cadeia única e longa, começando com “sk-proj-“, necessária para autenticar os teus pedidos API. Mais tarde, terás de copiar esta cadeia inteira para o código do Tutor de Línguas com IA.

Código para o Tutor de Línguas com IA

O código seguinte implementa a nossa aplicação de tutor de línguas com IA. Permite aos utilizadores falar frases em qualquer língua, que são depois transcritas, traduzidas para uma língua alvo selecionada e reproduzidas usando texto para fala (TTS). A app também oferece explicações gramaticais e frases de exemplo para ajudar na aprendizagem.

# www.makerguides.com
# Python 3.7
# openai 1.39.0
# PyAudio 0.2.11
# pinpong 0.6.1
# numpy 1.21.6

import sys
import time
import os
import threading
import tempfile
import numpy as np
import pyaudio
import io
import wave
import pygame

from openai import OpenAI
from pinpong.extension.unihiker import button_a, button_b

from qtpy.QtWidgets import (
    QApplication,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QLabel,
    QTextEdit,
    QScroller,
    QPushButton,
)
from qtpy.QtCore import Qt, QObject, Signal, QThread, Slot


API_KEY = "sk-proj-my-api_key"  # SET YOUR API KEY HERE!
DEVICE_INDEX = 2
SAMPLE_RATE  = 16000
CHANNELS     = 1
CHUNK        = 1024

LANGUAGES       = ["Japanese", "Italian", "German"]
DEFAULT_STATUS  = "Hold A: new  |  B: replay"
VOLUME_STEP     = 0.1      # increment per button press
INITIAL_VOLUME  = 0.8      # volume level when not muted
_volume = INITIAL_VOLUME   # float, 0.0 – 1.0

TTS_FILE = os.path.join(tempfile.gettempdir(), "tts_translation.mp3")

client = OpenAI(api_key=API_KEY)

pygame.mixer.init()


# STYLE ----------------------------------------------------------

ORA_BG      = "#fcdb03"   # bright yellow
ORA_DARK    = "#d4a800"   # darker yellow (pressed / language btn)
ORA_DIM     = "#fef08a"   # disabled button bg
ORA_TEXT    = "#000000"   # black text everywhere
ORA_DT      = "#888800"   # disabled button text

def _btn_style(bg=ORA_BG, fg=ORA_TEXT, en=ORA_BG,
               pr=ORA_DARK, ds=ORA_DIM, dt=ORA_DT):
    return (
        f"QPushButton          {{ background: {bg}; color: {fg};"
        f"                        border: none; font-size: 13px; }}"
        f"QPushButton:enabled  {{ background: {en}; }}"
        f"QPushButton:pressed  {{ background: {pr}; }}"
        f"QPushButton:disabled {{ background: {ds}; color: {dt}; }}"
    )



# WORKER THREAD ----------------------------------------------------

class AssistantWorker(QObject):

    status          = Signal(str)
    answer          = Signal(str)
    btn_explain_on  = Signal(bool)
    btn_examples_on = Signal(bool)
    language_changed = Signal(str)   

    def __init__(self):
        super().__init__()
        self._last_question    = ""
        self._last_translation = ""
        self._last_grammar     = ""
        self._last_examples    = ""
        self._tts_ready        = False

        self._lang_index = 0  
        self._language   = LANGUAGES[0]

        # Thread-safe flags
        self._explain_requested  = False
        self._examples_requested = False
        self._lang_requested     = False
        self._lock = threading.Lock()

        self._pa     = pyaudio.PyAudio()
        self._stream = self._pa.open(
            format=pyaudio.paInt16,
            channels=CHANNELS,
            rate=SAMPLE_RATE,
            input=True,
            input_device_index=DEVICE_INDEX,
            frames_per_buffer=CHUNK,
        )

    @Slot()
    def on_explain_clicked(self):
        with self._lock:
            self._explain_requested = True

    @Slot()
    def on_examples_clicked(self):
        with self._lock:
            self._examples_requested = True

    @Slot()
    def on_language_clicked(self):
        with self._lock:
            self._lang_requested = True

    def run(self):
        self.status.emit("Hold Button A to speak")

        while True:
            a_pressed = button_a.is_pressed()
            b_pressed = button_b.is_pressed()

            with self._lock:
                explain_req  = self._explain_requested
                examples_req = self._examples_requested
                lang_req     = self._lang_requested
                self._explain_requested  = False
                self._examples_requested = False
                self._lang_requested     = False

            if lang_req and not a_pressed:
                self._lang_index = (self._lang_index + 1) % len(LANGUAGES)
                self._language   = LANGUAGES[self._lang_index]
                self.language_changed.emit(self._language)

            elif a_pressed:
                self._clear_state()
                raw_pcm = record_while_held(self, self._stream)
                if not raw_pcm:
                    self.status.emit("Hold Button A to speak")
                    continue

                self.status.emit("Transcribing...")
                normalized = normalize(raw_pcm)
                wav_bytes  = pcm_to_wav(normalized)
                question   = transcribe(wav_bytes)

                self.status.emit(f"Translating to {self._language}...")
                translation = translate_only(question, self._language)

                self._last_question    = question
                self._last_translation = translation

                self._refresh_display()
                self.btn_explain_on.emit(True)
                self.btn_examples_on.emit(True)

                self.status.emit("Generating audio...")
                tts_text = extract_first_line(translation)
                generate_tts(tts_text, TTS_FILE)
                self._tts_ready = True
                self._set_default_status()
                play_audio(TTS_FILE)

            elif b_pressed:
                while button_b.is_pressed():
                    time.sleep(0.01)
                if self._tts_ready:
                    self.status.emit("Replaying...")
                    play_audio(TTS_FILE)
                    self._set_default_status()

            elif explain_req and self._last_translation:
                self.status.emit("Explaining grammar...")
                self._last_grammar = explain_grammar(
                    self._last_question, self._last_translation, self._language
                )
                self._refresh_display()
                self._set_default_status()

            elif examples_req and self._last_translation:
                self.status.emit("Generating examples...")
                self._last_examples = add_examples(
                    self._last_question, self._last_translation, self._language
                )
                self._refresh_display()
                self._set_default_status()

            else:
                time.sleep(0.02)

    def _set_default_status(self):
        self.status.emit(DEFAULT_STATUS)

    def _clear_state(self):
        self._last_question    = ""
        self._last_translation = ""
        self._last_grammar     = ""
        self._last_examples    = ""
        self._tts_ready        = False
        if pygame.mixer.music.get_busy():
            pygame.mixer.music.stop()
        if os.path.exists(TTS_FILE):
            try:
                os.remove(TTS_FILE)
            except OSError:
                pass
        self.answer.emit("")
        self.btn_explain_on.emit(False)
        self.btn_examples_on.emit(False)

    def _refresh_display(self):
        parts = []
        if self._last_question:
            parts.append(self._last_question)
        if self._last_translation:
            parts.append(self._last_translation)
        if self._last_grammar:
            parts.append(f"── Grammar ──\n{self._last_grammar}")
        if self._last_examples:
            parts.append(f"── Examples ──\n{self._last_examples}")
        self.answer.emit("\n\n".join(parts))



# GUI ----------------------------------------------------

class AssistantUI(QWidget):

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Voice Chatbot")
        self.setFixedSize(240, 320)

        # Orange window background, black text; white text area
        self.setStyleSheet(
            f"QWidget  {{ background-color: {ORA_BG}; color: {ORA_TEXT}; }}"
            f"QTextEdit {{ background-color: #ffffff; color: {ORA_TEXT};"
            f"             border: none; }}"
        )

        layout = QVBoxLayout(self)
        layout.setContentsMargins(4, 4, 4, 4)
        layout.setSpacing(3)

        lang_row = QHBoxLayout()
        lang_row.setSpacing(4)

        _dark_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_font_minus = QPushButton("-")
        self.btn_font_minus.setFixedSize(28, 26)
        self.btn_font_minus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_minus)

        self.btn_vol_down = QPushButton("<")
        self.btn_vol_down.setFixedSize(28, 26)
        self.btn_vol_down.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_down)

        self.btn_language = QPushButton(LANGUAGES[0])
        self.btn_language.setFixedHeight(26)
        self.btn_language.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_language, stretch=1)

        self.btn_vol_up = QPushButton(">")
        self.btn_vol_up.setFixedSize(28, 26)
        self.btn_vol_up.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_up)

        self.btn_font_plus = QPushButton("+")
        self.btn_font_plus.setFixedSize(28, 26)
        self.btn_font_plus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_plus)

        layout.addLayout(lang_row)

        self._font_size = 10
        self.btn_font_plus.clicked.connect(self._font_increase)
        self.btn_font_minus.clicked.connect(self._font_decrease)
        self.btn_vol_down.clicked.connect(self._vol_decrease)
        self.btn_vol_up.clicked.connect(self._vol_increase)

        self.status = QLabel("Starting...")
        self.status.setAlignment(Qt.AlignCenter)
        self.status.setStyleSheet(
            "QLabel { background-color: #ffffff; color: #000000; padding: 1px; }"
        )
        layout.addWidget(self.status)

        self.text = QTextEdit()
        self.text.setReadOnly(True)
        self.text.setTextInteractionFlags(Qt.NoTextInteraction)
        layout.addWidget(self.text, stretch=1)

        QScroller.grabGesture(
            self.text.viewport(),
            QScroller.LeftMouseButtonGesture
        )

        btn_row = QHBoxLayout()
        btn_row.setSpacing(4)

        _scroll_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_scroll_up = QPushButton("▲")
        self.btn_scroll_up.setFixedSize(40, 26)
        self.btn_scroll_up.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_up)

        self.btn_explain  = QPushButton("Explain")
        self.btn_examples = QPushButton("Examples")

        for btn in (self.btn_explain, self.btn_examples):
            btn.setEnabled(False)
            btn.setFixedHeight(26)
            btn.setStyleSheet(_btn_style())
            btn_row.addWidget(btn, stretch=1)

        self.btn_scroll_down = QPushButton("▼")
        self.btn_scroll_down.setFixedSize(40, 26)
        self.btn_scroll_down.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_down)

        layout.addLayout(btn_row)

        self.btn_scroll_down.clicked.connect(self._scroll_down)
        self.btn_scroll_up.clicked.connect(self._scroll_up)


    def update_status(self, txt):
        self.status.setText(txt)

    def update_answer(self, txt):
        self.text.clear()
        self.text.setPlainText(txt)
        self.text.verticalScrollBar().setValue(0)

    def set_explain_enabled(self, enabled: bool):
        self.btn_explain.setEnabled(enabled)

    def set_examples_enabled(self, enabled: bool):
        self.btn_examples.setEnabled(enabled)

    def update_language_label(self, lang: str):
        self.btn_language.setText(lang)

    def _scroll_down(self):
        sb = self.text.verticalScrollBar()
        sb.setValue(sb.value() + self.text.viewport().height())

    def _scroll_up(self):
        sb = self.text.verticalScrollBar()
        sb.setValue(sb.value() - self.text.viewport().height())

    def _font_increase(self):
        self._font_size = min(self._font_size + 1, 28)
        self._apply_font_size()

    def _font_decrease(self):
        self._font_size = max(self._font_size - 1, 6)
        self._apply_font_size()

    def _apply_font_size(self):
        font = self.text.font()
        font.setPointSize(self._font_size)
        self.text.setFont(font)


    def _vol_increase(self):
        global _volume
        _volume = min(round(_volume + VOLUME_STEP, 1), 1.0)

    def _vol_decrease(self):
        global _volume
        _volume = max(round(_volume - VOLUME_STEP, 1), 0.0)



# AUDIO HELPERS  ----------------------------------------------------

def normalize(pcm_bytes: bytes) -> bytes:
    samples = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32)
    peak = np.max(np.abs(samples))
    if peak == 0:
        return pcm_bytes
    gain = (0.9 * 32767) / peak
    return np.clip(samples * gain, -32768, 32767).astype(np.int16).tobytes()


def record_while_held(worker: AssistantWorker, stream) -> bytes:
    try:
        while stream.get_read_available() > 0:
            stream.read(stream.get_read_available(), exception_on_overflow=False)
    except Exception:
        pass

    worker.status.emit("Recording...")
    frames = []
    while button_a.is_pressed():
        frames.append(stream.read(CHUNK, exception_on_overflow=False))
    return b"".join(frames)


def pcm_to_wav(pcm_bytes: bytes) -> bytes:
    buf = io.BytesIO()
    with wave.open(buf, "wb") as wf:
        wf.setnchannels(CHANNELS)
        wf.setsampwidth(2)
        wf.setframerate(SAMPLE_RATE)
        wf.writeframes(pcm_bytes)
    return buf.getvalue()


def generate_tts(text: str, filepath: str) -> None:
    """Call OpenAI TTS and save to filepath (mp3)."""
    response = client.audio.speech.create(
        model="tts-1",
        voice="alloy",
        input=text,
    )
    with open(filepath, "wb") as f:
        f.write(response.content)


def play_audio(filepath: str) -> None:
    """Play audio at the current volume, then mute the output again."""
    pygame.mixer.music.set_volume(_volume)
    pygame.mixer.music.load(filepath)
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():
        time.sleep(0.05)
    pygame.mixer.music.set_volume(0.0) 


# OPENAI HELPERS ------------------------------------------------------

def transcribe(wav_bytes: bytes) -> str:
    audio_file      = io.BytesIO(wav_bytes)
    audio_file.name = "recording.wav"
    response = client.audio.transcriptions.create(
        model="whisper-1",
        file=audio_file,
        language="en",
        temperature=0,
    )
    return response.text


def _script_hint(language: str) -> str:
    """Return a script hint for the system prompt based on language."""
    hints = {
        "Japanese": "in Kanji/Kana on one line, then the romaji reading on the next line",
        "Italian":  "in Italian script",
        "German":   "in German script",
    }
    return hints.get(language, "in the target language's script")


def translate_only(question: str, language: str) -> str:
    hint = _script_hint(language)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"Translate the given English phrase to {language}. "
                    f"Provide the translation {hint}. "
                    "Do NOT include grammar explanations or example sentences."
                ),
            },
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content


def extract_first_line(translation: str) -> str:
    """Return the first line of the translation for TTS (skips romaji line)."""
    return translation.splitlines()[0].strip() if translation else translation


def explain_grammar(question: str, translation: str, language: str) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"You are a {language} language teacher. "
                    f"Explain the grammar of the {language} translation clearly and concisely. "
                    "Do NOT provide additional example sentences."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Original English: {question}\n"
                    f"{language} translation: {translation}\n\n"
                    "Please explain the grammar."
                ),
            },
        ],
    )
    return response.choices[0].message.content


def add_examples(question: str, translation: str, language: str) -> str:
    hint = _script_hint(language)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    f"You are a {language} language teacher. "
                    f"Provide 2-3 natural example sentences in {language} ({hint}) "
                    "that illustrate the same grammar pattern or vocabulary. "
                    "Include a short English translation for each."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Original English: {question}\n"
                    f"{language} translation: {translation}\n\n"
                    "Please provide example sentences."
                ),
            },
        ],
    )
    return response.choices[0].message.content



# MAIN ------------------------------------------------------

def main():
    app = QApplication(sys.argv)

    ui     = AssistantUI()
    worker = AssistantWorker()
    thread = QThread()

    worker.moveToThread(thread)
    thread.started.connect(worker.run)
    worker.status.connect(ui.update_status)
    worker.answer.connect(ui.update_answer)
    worker.btn_explain_on.connect(ui.set_explain_enabled)
    worker.btn_examples_on.connect(ui.set_examples_enabled)
    worker.language_changed.connect(ui.update_language_label)

    ui.btn_explain.clicked.connect(worker.on_explain_clicked,  Qt.DirectConnection)
    ui.btn_examples.clicked.connect(worker.on_examples_clicked, Qt.DirectConnection)
    ui.btn_language.clicked.connect(worker.on_language_clicked, Qt.DirectConnection)

    ui.show()
    thread.start()

    sys.exit(app.exec())


if __name__ == "__main__":
    main()

Importações

O programa começa por importar os módulos e bibliotecas Python necessários. Inclui módulos padrão como sys, time, os, e threading para interação com o sistema, temporização, manipulação de ficheiros e concorrência. Usa numpy para operações numéricas em dados de áudio, pyaudio para gravação de áudio, e pygame para reprodução de áudio.

O código também importa a biblioteca cliente OpenAI para aceder aos serviços de IA, e botões de hardware específicos button_a e button_b da extensão UNIHIKER. Para a interface gráfica (GUI), usa qtpy para criar widgets e gerir sinais e slots.

Nota que vais precisar de instalar a biblioteca OpenAI, pois não está pré-instalada no UNIHIKER M10. Se precisares de ajuda com isso, vê o Voice Assistant on UNIHIKER M10 with OpenAI tutorial.

Constantes e Configuração

Várias constantes definem o comportamento e a aparência da aplicação. Incluem a chave API da OpenAI, parâmetros do dispositivo de entrada de áudio como taxa de amostragem e canais, e constantes relacionadas com a UI como línguas suportadas e códigos de cor para estilizar botões e fundos.

Parâmetros de controlo de volume também são definidos, incluindo o volume inicial e o passo para ajustes de volume.

API_KEY = "sk-proj-my-api_key"
DEVICE_INDEX = 2
SAMPLE_RATE  = 16000
CHANNELS     = 1
CHUNK        = 1024

LANGUAGES       = ["Japanese", "Italian", "German"]
DEFAULT_STATUS  = "Hold A: new  |  B: replay"
VOLUME_STEP     = 0.1
INITIAL_VOLUME  = 0.8
_volume = INITIAL_VOLUME

Lembra-te que tens de substituir o valor da constante API_KEY pela tua própria chave API!

Estilo dos Botões

A função _btn_style() devolve uma string de stylesheet que define a aparência dos botões em diferentes estados (normal, pressionado, desativado). Esta função centraliza o estilo para manter uma aparência consistente na UI.

Classe AssistantWorker

Esta classe encapsula a lógica principal que corre numa thread separada para manter a UI responsiva. Gere a gravação de áudio, interação com as APIs da OpenAI e gestão de estado.

A classe define sinais para comunicar atualizações de estado, respostas, estados dos botões e mudanças de língua de volta para a UI.

No construtor, inicializa variáveis para guardar a última pergunta, tradução, explicação gramatical e frases de exemplo. Também configura o stream de entrada de áudio usando pyaudio com o dispositivo e parâmetros especificados.

class AssistantWorker(QObject):
    status          = Signal(str)
    answer          = Signal(str)
    btn_explain_on  = Signal(bool)
    btn_examples_on = Signal(bool)
    language_changed = Signal(str)

    def __init__(self):
        super().__init__()
        self._last_question    = ""
        self._last_translation = ""
        self._last_grammar     = ""
        self._last_examples    = ""
        self._tts_ready        = False

        self._lang_index = 0
        self._language   = LANGUAGES[0]

        self._explain_requested  = False
        self._examples_requested = False
        self._lang_requested     = False
        self._lock = threading.Lock()

        self._pa     = pyaudio.PyAudio()
        self._stream = self._pa.open(
            format=pyaudio.paInt16,
            channels=CHANNELS,
            rate=SAMPLE_RATE,
            input=True,
            input_device_index=DEVICE_INDEX,
            frames_per_buffer=CHUNK,
        )

A classe fornece métodos slot para tratar cliques nos botões para pedir explicações gramaticais, frases de exemplo e mudanças de língua. Estes métodos definem flags thread-safe que o loop principal monitoriza.

Loop de Execução do AssistantWorker

O método run() contém o loop principal que verifica continuamente o estado dos botões de hardware e processa a entrada do utilizador em conformidade.

Quando o botão A está pressionado, grava áudio do microfone. Quando o botão A é solto, normaliza o áudio gravado, converte-o para formato WAV e envia-o para o modelo Whisper da OpenAI para transcrição. O texto transcrito em inglês é depois traduzido para a língua selecionada usando o modelo GPT da OpenAI.

De seguida, o texto traduzido é exibido, e um ficheiro de áudio TTS é gerado e reproduzido. O botão B permite reproduzir novamente o último áudio gerado. Pressionar o botão de língua alterna entre as línguas suportadas.

Se o utilizador pedir explicações gramaticais ou frases de exemplo, o worker chama os endpoints apropriados da API da OpenAI para gerar o conteúdo e atualiza a exibição.

def run(self):
    self.status.emit("Hold Button A to speak")

    while True:
        a_pressed = button_a.is_pressed()
        b_pressed = button_b.is_pressed()

        with self._lock:
            explain_req  = self._explain_requested
            examples_req = self._examples_requested
            lang_req     = self._lang_requested
            self._explain_requested  = False
            self._examples_requested = False
            self._lang_requested     = False

        if lang_req and not a_pressed:
            self._lang_index = (self._lang_index + 1) % len(LANGUAGES)
            self._language   = LANGUAGES[self._lang_index]
            self.language_changed.emit(self._language)

        elif a_pressed:
            self._clear_state()
            raw_pcm = record_while_held(self, self._stream)
            if not raw_pcm:
                self.status.emit("Hold Button A to speak")
                continue

            self.status.emit("Transcribing...")
            normalized = normalize(raw_pcm)
            wav_bytes  = pcm_to_wav(normalized)
            question   = transcribe(wav_bytes)

            self.status.emit(f"Translating to {self._language}...")
            translation = translate_only(question, self._language)

            self._last_question    = question
            self._last_translation = translation

            self._refresh_display()
            self.btn_explain_on.emit(True)
            self.btn_examples_on.emit(True)

            self.status.emit("Generating audio...")
            tts_text = extract_first_line(translation)
            generate_tts(tts_text, TTS_FILE)
            self._tts_ready = True
            self._set_default_status()
            play_audio(TTS_FILE)

        elif b_pressed:
            while button_b.is_pressed():
                time.sleep(0.01)
            if self._tts_ready:
                self.status.emit("Replaying...")
                play_audio(TTS_FILE)
                self._set_default_status()

        elif explain_req and self._last_translation:
            self.status.emit("Explaining grammar...")
            self._last_grammar = explain_grammar(
                self._last_question, self._last_translation, self._language
            )
            self._refresh_display()
            self._set_default_status()

        elif examples_req and self._last_translation:
            self.status.emit("Generating examples...")
            self._last_examples = add_examples(
                self._last_question, self._last_translation, self._language
            )
            self._refresh_display()
            self._set_default_status()

        else:
            time.sleep(0.02)

Métodos de Gestão de Estado

A classe worker inclui métodos auxiliares para reiniciar o estado interno, atualizar o texto exibido e definir a mensagem de estado padrão. Estes métodos garantem que a UI reflete corretamente o estado atual da aplicação.

Classe AssistantUI

Esta classe define a interface gráfica usando widgets Qt. Cria uma janela de tamanho fixo com fundo laranja e texto preto, correspondendo ao esquema de cores definido anteriormente.

A UI consiste numa linha superior com botões para ajuste do tamanho da fonte, controlo de volume e seleção de língua. Abaixo, um rótulo de estado exibe mensagens para o utilizador. A área principal de texto mostra o texto transcrito, traduzido e explicativo.

Na parte inferior, botões permitem rolar o texto e pedir explicações gramaticais ou frases de exemplo. Os botões são estilizados de forma consistente usando os estilos definidos anteriormente.

A classe fornece métodos para atualizar o texto de estado, a resposta exibida, ativar ou desativar botões e mudar o rótulo da língua. Também trata interações do utilizador para rolar e ajustar tamanho da fonte e volume.

class AssistantUI(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Voice Chatbot")
        self.setFixedSize(240, 320)

        self.setStyleSheet(
            f"QWidget  {{ background-color: {ORA_BG}; color: {ORA_TEXT}; }}"
            f"QTextEdit {{ background-color: #ffffff; color: {ORA_TEXT};"
            f"             border: none; }}"
        )

        layout = QVBoxLayout(self)
        layout.setContentsMargins(4, 4, 4, 4)
        layout.setSpacing(3)

        lang_row = QHBoxLayout()
        lang_row.setSpacing(4)

        _dark_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_font_minus = QPushButton("-")
        self.btn_font_minus.setFixedSize(28, 26)
        self.btn_font_minus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_minus)

        self.btn_vol_down = QPushButton("<")
        self.btn_vol_down.setFixedSize(28, 26)
        self.btn_vol_down.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_down)

        self.btn_language = QPushButton(LANGUAGES[0])
        self.btn_language.setFixedHeight(26)
        self.btn_language.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_language, stretch=1)

        self.btn_vol_up = QPushButton(">")
        self.btn_vol_up.setFixedSize(28, 26)
        self.btn_vol_up.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_vol_up)

        self.btn_font_plus = QPushButton("+")
        self.btn_font_plus.setFixedSize(28, 26)
        self.btn_font_plus.setStyleSheet(_dark_style)
        lang_row.addWidget(self.btn_font_plus)

        layout.addLayout(lang_row)

        self._font_size = 10
        self.btn_font_plus.clicked.connect(self._font_increase)
        self.btn_font_minus.clicked.connect(self._font_decrease)
        self.btn_vol_down.clicked.connect(self._vol_decrease)
        self.btn_vol_up.clicked.connect(self._vol_increase)

        self.status = QLabel("Starting...")
        self.status.setAlignment(Qt.AlignCenter)
        self.status.setStyleSheet(
            "QLabel { background-color: #ffffff; color: #000000; padding: 1px; }"
        )
        layout.addWidget(self.status)

        self.text = QTextEdit()
        self.text.setReadOnly(True)
        self.text.setTextInteractionFlags(Qt.NoTextInteraction)
        layout.addWidget(self.text, stretch=1)

        QScroller.grabGesture(
            self.text.viewport(),
            QScroller.LeftMouseButtonGesture
        )

        btn_row = QHBoxLayout()
        btn_row.setSpacing(4)

        _scroll_style = _btn_style(bg=ORA_DARK, en=ORA_DARK, pr="#8a3d00")

        self.btn_scroll_up = QPushButton("▲")
        self.btn_scroll_up.setFixedSize(40, 26)
        self.btn_scroll_up.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_up)

        self.btn_explain  = QPushButton("Explain")
        self.btn_examples = QPushButton("Examples")

        for btn in (self.btn_explain, self.btn_examples):
            btn.setEnabled(False)
            btn.setFixedHeight(26)
            btn.setStyleSheet(_btn_style())
            btn_row.addWidget(btn, stretch=1)

        self.btn_scroll_down = QPushButton("▼")
        self.btn_scroll_down.setFixedSize(40, 26)
        self.btn_scroll_down.setStyleSheet(_scroll_style)
        btn_row.addWidget(self.btn_scroll_down)

        layout.addLayout(btn_row)

        self.btn_scroll_down.clicked.connect(self._scroll_down)
        self.btn_scroll_up.clicked.connect(self._scroll_up)

Funções Auxiliares de Áudio

Várias funções auxiliares tratam tarefas de processamento de áudio. A função normalize() ajusta o áudio PCM gravado para maximizar o volume sem distorção. record_while_held() grava áudio do microfone enquanto o botão A está pressionado. E a função pcm_to_wav() converte bytes PCM brutos em formato WAV, necessário para transcrição.

De seguida temos a função generate_tts() que envia texto para a API TTS da OpenAI e guarda o ficheiro de áudio resultante. Finalmente, play_audio() reproduz o áudio gerado usando pygame ao volume atual.

Funções Auxiliares da OpenAI

Estas funções interagem com a API da OpenAI para realizar transcrição, tradução, explicação gramatical e geração de frases de exemplo.

A função transcribe() envia o áudio WAV gravado para o modelo Whisper para obter texto em inglês. translate_only() traduz a pergunta em inglês para a língua selecionada sem explicações adicionais.

As funções explain_grammar() e add_examples() pedem explicações gramaticais e frases de exemplo respetivamente, usando GPT com prompts adaptados para ensino de línguas.

Função Principal

A função main() inicializa a aplicação Qt, cria instâncias das classes UI e worker, e configura uma thread separada para o worker correr em paralelo.

Conecta sinais e slots entre o worker e a UI para atualizar a interface com base no progresso do worker e nas interações do utilizador. Finalmente, inicia o loop de eventos da aplicação.

def main():
    app = QApplication(sys.argv)

    ui     = AssistantUI()
    worker = AssistantWorker()
    thread = QThread()

    worker.moveToThread(thread)
    thread.started.connect(worker.run)
    worker.status.connect(ui.update_status)
    worker.answer.connect(ui.update_answer)
    worker.btn_explain_on.connect(ui.set_explain_enabled)
    worker.btn_examples_on.connect(ui.set_examples_enabled)
    worker.language_changed.connect(ui.update_language_label)

    ui.btn_explain.clicked.connect(worker.on_explain_clicked,  Qt.DirectConnection)
    ui.btn_examples.clicked.connect(worker.on_examples_clicked, Qt.DirectConnection)
    ui.btn_language.clicked.connect(worker.on_language_clicked, Qt.DirectConnection)

    ui.show()
    thread.start()

    sys.exit(app.exec())

Em resumo, este código integra entrada de botões de hardware, processamento de áudio, serviços de IA para línguas e uma GUI responsiva para criar um tutor de línguas interativo por voz. Aproveita os modelos da OpenAI para transcrição, tradução e ensino de línguas.

Conclusões

Neste tutorial aprendeste a implementar um simples Tutor de Línguas com IA no UNIHIKER M10 usando os serviços da OpenAI. Recomendo também que leias o Voice Assistant on UNIHIKER M10 with OpenAI tutorial, que cobre alguns conceitos básicos que não fazem parte deste tutorial.

Embora o Tutor de Línguas com IA deste tutorial já seja útil para aprender línguas, há muitas extensões possíveis para o tornar ainda mais útil. Por exemplo, o programa poderia guardar traduções e explicações para rever mais tarde. Poderia gerar pequenas histórias em torno das frases e lê-las em voz alta. Poderia extrair verbos e substantivos para treino separado. E muito mais…

Divirte-te a expandir o tutor para os teus propósitos, e se tiveres alguma dúvida, não hesites em deixar nos comentários.

Boa Tinkering 😉