:從語音識別到UDP指令的完整實(shí)現(xiàn))
老實(shí)說最早看到“語音控制無人機(jī)”這種組合我心里想過直升機(jī)本來通過遙控器就能指揮多一個(gè)語音識別折騰一遍是不是自己給自己找麻煩可真讓我動手寫基于 Python 的語音控制 Tello EDU 項(xiàng)目之后我才發(fā)現(xiàn)這里面的技術(shù)鏈路遠(yuǎn)比“動嘴”更有意思麥克風(fēng)采集聲音、語音識別轉(zhuǎn)文字、自然語言轉(zhuǎn) SDK 指令、UDP 數(shù)據(jù)包在局域網(wǎng)里送達(dá)無人機(jī)最后電機(jī)響應(yīng)。整個(gè)過程是很完整的“語音交互嵌入式控制”小閉環(huán)。這個(gè)項(xiàng)目很適合三類人一是準(zhǔn)備學(xué) Python 網(wǎng)絡(luò)編程的二是想接觸無人機(jī) SDK 的三是想在家里輕松給 Tello EDU 下達(dá)指令的。讀完這篇文章你至少能收獲一套可以直接跑的語音控制腳本框架以及我在調(diào)試過程中踩過的坑和放棄掉的方案。我盡量把每一步背后的原理和“為什么要這么寫”講清楚這樣你以后換成別的型號或者別的語音引擎也能自己改。1. 為什么我會把語音控制和Tello EDU放在一起1.1 項(xiàng)目起點(diǎn)鍵盤控制玩膩了想換一種交互方式我之前自己寫過基于 Python 的鍵盤控制腳本用方向鍵控制 Tello EDU。說實(shí)話用鍵盤飛無人機(jī)也算方便但每次飛行都要坐在筆記本面前視線要在屏幕、無人機(jī)和手機(jī)之間來回切換。更麻煩的是鍵盤控制本質(zhì)上是在發(fā)“增量指令”按一下前進(jìn)鍵就飛 20 厘米再按一下又飛 20 厘米。這種控制模式雖然安全卻完全沒有“人機(jī)協(xié)同”的感覺。后來我玩到一個(gè)語音助手項(xiàng)目突然想到既然語音識別已經(jīng)能做到把“打開空調(diào)”轉(zhuǎn)成一串指令那我把“起飛”、“降落”、“向前飛”這些中文命令轉(zhuǎn)成 Tello EDU 能聽懂的 UDP 指令不就行了這樣可以解放雙手同時(shí)驗(yàn)證一條挺完整的 AIoT 技術(shù)路線語音入口 - 語義解析 - 設(shè)備控制。1.2 語音控制真正要解決的問題語音控制無人機(jī)核心不是“把聲音變成文字”而是“把意圖變成可執(zhí)行動作”。比如你說“前進(jìn)”如果你在客廳里無人機(jī)直接盲目往前飛 5 米大概率撞墻。所以一個(gè)成熟的項(xiàng)目必須在語音識別之后加上距離參數(shù)、速度限制、方向限制和緊急降落指令。我給自己定的項(xiàng)目目標(biāo)是支持中文語音指令覆蓋起飛、降落、前后左右移動。能設(shè)置默認(rèn)飛行距離確保每次動作幅度不會太大。識別失敗時(shí)絕對不能發(fā)送誤指令無人機(jī)必須保持懸停。語音識別到“急停”或“降落”時(shí)要優(yōu)先執(zhí)行安全動作。后來實(shí)際做下來這四個(gè)目標(biāo)每一項(xiàng)都對應(yīng)一個(gè)技術(shù)細(xì)節(jié)并不是簡單把識別 API 接進(jìn)來就行。1.3 Tello EDU 本身適不適合語音控制Tello EDU 是一臺面向教育和編程的飛行設(shè)備幾百克級別在室內(nèi)小空間里懸停比較穩(wěn)定螺旋槳有保護(hù)罩適合做實(shí)驗(yàn)。它相比消費(fèi)版的優(yōu)勢是支持更開放的 SDK 指令而且有配套的 Python 生態(tài)。雖然它的算力不可能本地處理語音但我們可以用電腦完成語音識別再通過它自帶的 Wi-Fi 熱點(diǎn)把指令傳過去相當(dāng)于把“耳朵”放在了電腦上把“身體”留在無人機(jī)里。這種“外腦控制”的方式也是最安全、最容易調(diào)試的方式。哪怕某個(gè)環(huán)節(jié)崩了你還能隨時(shí)切回鍵盤模式接管。2. 先搞明白 Tello EDU 控制命令到底怎么發(fā)2.1 SDK 指令的本質(zhì)一句話 UDP 包Tello EDU 的控制協(xié)議很簡單本質(zhì)就是你的電腦連上無人機(jī)發(fā)出的 Wi-Fi 熱點(diǎn)之后通過 UDP 向固定 IP 和端口發(fā)一段 ASCII 文本命令然后無人機(jī)會回復(fù)一段文本比如 “ok” 或者 “error”。這里的固定 IP 是192.168.10.1固定端口是8889。如果你連過它的 Wi-Fi就會發(fā)現(xiàn)自己電腦被分配了一個(gè)192.168.10.x的地址而無人機(jī)自己就是192.168.10.1。這就是一個(gè)標(biāo)準(zhǔn)的局域網(wǎng) UDP 通信。需要注意Tello EDU 對每條命令都會有一個(gè)回復(fù)但這個(gè)回復(fù)不是馬上來有時(shí)候需要幾百毫秒甚至更久所以你的 socket 接收必須設(shè)置超時(shí)不能無限等下去。首次連接時(shí)無人機(jī)默認(rèn)不是處于可接受 SDK 指令的狀態(tài)必須先發(fā)送command這條“握手”指令。成功之后它會回復(fù)ok然后你才可以把takeoff、land等指令發(fā)過去。2.2 常用控制指令表下面是我在項(xiàng)目中用到的核心指令新手可以先背熟這一小撮。指令參數(shù)說明實(shí)際效果command無激活 SDK 模式takeoff無起飛并懸停約 1 米高度land無原地降落up xx 為 20~500垂直上升 x 厘米down xx 為 20~500垂直下降 x 厘米forward xx 為 20~500前進(jìn) x 厘米back xx 為 20~500后退 x 厘米left xx 為 20~500向左平移 x 厘米right xx 為 20~500向右平移 x 厘米cw xx 為 1~360順時(shí)針旋轉(zhuǎn) x 度ccw xx 為 1~360逆時(shí)針旋轉(zhuǎn) x 度flip l/flip rl/r/f/b向左/右/前/后翻滾stop無立即懸停緊急停止battery?無返回當(dāng)前電量百分比注意一個(gè)小細(xì)節(jié)left和right是“橫向平移”而cw和ccw是“原地旋轉(zhuǎn)”這兩個(gè)要分清楚。剛開始語音控制時(shí)很多人會把“左轉(zhuǎn)”和“左移”混為一談導(dǎo)致無人機(jī)突然改變機(jī)頭朝向而不是平移非常容易撞到障礙物。2.3 用 Python 的最小控制代碼我先寫了一個(gè)不摻任何語音識別內(nèi)容的最小控制腳本目的是驗(yàn)證 socket 通信鏈路。這一步搞不定后面做語音識別也是白搭。import socket TELLO_IP 192.168.10.1 TELLO_PORT 8889 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(5) def send_command(cmd: str) - str: 發(fā)送一條 Tello 指令并接收無人機(jī)回復(fù)。 如果沒有回復(fù)返回空字符串。 try: sock.sendto(cmd.encode(utf-8), (TELLO_IP, TELLO_PORT)) data, _ sock.recvfrom(1024) return data.decode(utf-8).strip() except socket.timeout: print(f[超時(shí)] 指令 {cmd} 未收到回復(fù)) return print(send_command(command)) print(send_command(battery?))跑通這個(gè)腳本你會看到終端打印出ok和電量數(shù)字。如果卡在超時(shí)基本就是沒有連上無人機(jī)的 Wi-Fi或者 IP 寫錯(cuò)了。這一步我強(qiáng)烈建議先測試因?yàn)楹罄m(xù)所有語音功能都建立在這個(gè) socket 基礎(chǔ)上。有個(gè)我一開始忽略的點(diǎn)sock.sendto發(fā)送指令后無人機(jī)確認(rèn)回復(fù)的地址不一定每次都一樣有些固件版本會從一個(gè)臨時(shí)的端口回?cái)?shù)據(jù)。如果接收窗口寫得不對會出現(xiàn)“指令發(fā)出去了但收不到回復(fù)”的現(xiàn)象。最簡單的做法是不要過早關(guān)閉 socket保持同一個(gè) UDP socket 實(shí)例循環(huán)使用。3. 語音識別的關(guān)鍵選型在線接口還是本地離線模型3.1 不要一上來就選在線 API很多人做語音控制第一步就接一個(gè)在線語音識別 API因?yàn)槊赓M(fèi)的方案看起來簡單兩行代碼就能出結(jié)果。但用在無人機(jī)控制場景在線識別有幾個(gè)致命問題每一次識別都要把音頻上傳到云端往返時(shí)間通常是 1~3 秒。如果網(wǎng)絡(luò)抖動識別結(jié)果會延遲很久無人機(jī)接收到指令時(shí)會“突然沖出去”。免費(fèi)限額和并發(fā)限制在不同場景下表現(xiàn)差異很大。在飛行實(shí)驗(yàn)中外部網(wǎng)絡(luò)本來就是可選資源沒有必要讓飛行動作依賴它。我并不是說在線 API 完全不能用而是在“實(shí)時(shí)控制無人機(jī)”這個(gè)特定需求下延遲是不可接受的。你愿意為一句“降落”多等兩秒嗎我不愿意我聽到螺旋槳聲音都覺得危險(xiǎn)。3.2 本地離線識別是更穩(wěn)的選擇在離線方案里Vosk 是一個(gè)比較成熟的開源語音識別工具支持中文模型不依賴外部網(wǎng)絡(luò)。模型文件下載后在本地運(yùn)行解碼速度遠(yuǎn)快于在線方案實(shí)測在普通筆記本電腦上識別一個(gè)詞基本在 0.3~1 秒左右這個(gè)延遲對無人機(jī)控制來說是可以接受的。我在項(xiàng)目里用的就是 Vosk 的中文小模型。它體積不大準(zhǔn)確率在“有限指令集”里完全夠用。你可以先安裝 Vosk 和 SpeechRecognition 相關(guān)庫pip install vosk speechrecognition pyaudiopyaudio在 Linux 和 macOS 上可能需要額外安裝系統(tǒng)依賴Windows 上建議直接安裝帶預(yù)編譯的包。裝完之后關(guān)鍵是配置正確的音頻流參數(shù)采樣率16000、單聲道、位深16bit。Vosk 默認(rèn)需要這個(gè)格式否則識別結(jié)果會亂掉。3.3 我不是直接把整段語音都丟給模型語音控制無人機(jī)和語音輸入法不一樣不需要識別長句只需要識別“起飛”“降落”“前進(jìn)五十”“左轉(zhuǎn)”這類短指令。所以我設(shè)計(jì)了一個(gè)關(guān)鍵詞匹配策略先把麥克風(fēng)拾取的一小段音頻送到 Vosk。模型返回一個(gè)包含多個(gè)候選詞的 JSON我只關(guān)心里面出現(xiàn)的動作詞。如果動作詞命中了預(yù)設(shè)指令表再執(zhí)行后續(xù)轉(zhuǎn)換。這種策略的好處是即使模型把“前進(jìn)”識別成“前盡”或“前金”我也可以通過拼音相似度或者包含關(guān)系把它匹配到“前進(jìn)”指令上。如果嚴(yán)格到必須一字不差語音識別里的同音字問題會讓你崩潰。4. 實(shí)現(xiàn)語音到飛行動作映射一份可以直接跑的代碼4.1 整體架構(gòu)我的腳本分三個(gè)模塊音頻采集、語義指令匹配、UDP 飛行指令發(fā)送。用一個(gè)主循環(huán)把它們串起來大致流程是初始化 UDP socket 并發(fā)送command進(jìn)入 SDK 模式。初始化語音識別器和麥克風(fēng)流。每輪循環(huán)從麥克風(fēng)讀取約 2 秒的音頻。Vosk 識別出文本。對文本做關(guān)鍵詞映射判斷是否需要執(zhí)行動作。如果需要執(zhí)行解析距離/角度參數(shù)調(diào)用send_command發(fā)送 UDP 包。每次指令之間加冷卻時(shí)間防止連續(xù)誤觸發(fā)。4.2 主程序示例下面的代碼是我化簡后的版本不依賴任何外部云接口適合直接理解核心邏輯import json import queue import socket import sys import time import pyaudio from vosk import Model, KaldiRecognizer TELLO_IP 192.168.10.1 TELLO_PORT 8889 DEFAULT_DISTANCE 40 # 平移距離默認(rèn)厘米數(shù) DEFAULT_ANGLE 90 # 旋轉(zhuǎn)角度默認(rèn)度數(shù) COOLDOWN_SECONDS 2 # 兩條動作指令之間的冷卻時(shí)間 # 這一份映射可以按自己需求調(diào)整 keyword_map { 起飛: takeoff, 降落: land, 急停: land, 前進(jìn): forward, 后退: back, 左轉(zhuǎn): ccw, 右轉(zhuǎn): cw, 左移: left, 右移: right, 上升: up, 下降: down, } sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(5) def send_command(cmd: str) - str: try: sock.sendto(cmd.encode(utf-8), (TELLO_IP, TELLO_PORT)) data, _ sock.recvfrom(1024) return data.decode(utf-8).strip() except socket.timeout: return def build_tello_command(keyword: str) - str: 把識別出來的關(guān)鍵詞轉(zhuǎn)成 Tello 指令距離和角度目前用默認(rèn)值。 base keyword_map.get(keyword) if base is None: return None if base in (forward, back, left, right, up, down): return f{base} {DEFAULT_DISTANCE} if base in (cw, ccw): return f{base} {DEFAULT_ANGLE} return base def main(): # 連接無人機(jī) print(正在連接 Tello EDU ...) print(send_command(command)) # 初始化 Vosk 模型 model Model(models/vosk-model-small-cn-0.3) recognizer KaldiRecognizer(model, 16000) # 打開麥克風(fēng)音頻流 audio_queue queue.Queue() def audio_callback(in_data, frame_count, time_info, status): audio_queue.put(in_data) return (None, pyaudio.paContinue) audio pyaudio.PyAudio() stream audio.open( formatpyaudio.paInt16, channels1, rate16000, inputTrue, frames_per_buffer4000, stream_callbackaudio_callback, ) stream.start_stream() last_command_time 0 print(語音控制已就緒等待指令……) while True: data audio_queue.get() if not data: continue if recognizer.AcceptWaveform(data): result json.loads(recognizer.Result()) text result.get(text, ).strip() if not text: continue print(f[識別] {text}) matched_keyword None for word in keyword_map: if word in text: matched_keyword word break if matched_keyword is None: continue now time.time() if now - last_command_time COOLDOWN_SECONDS: print([忽略] 指令間隔太短) continue last_command_time now cmd build_tello_command(matched_keyword) if cmd: print(f[發(fā)送] {cmd}) response send_command(cmd) print(f[回復(fù)] {response}) if __name__ __main__: main()代碼本身并不復(fù)雜。要注意build_tello_command里用了固定距離和固定角度這是故意的對剛接觸語音控制的用戶來說固定 40 厘米比每次語音解析數(shù)字更安全。我之前試圖讓用戶說“前進(jìn)三十”然后識別數(shù)字結(jié)果識別精度和漢字?jǐn)?shù)字轉(zhuǎn)換帶來的麻煩遠(yuǎn)超收益。先把固定參數(shù)跑通再慢慢加可變參數(shù)。4.3 如何讓距離參數(shù)也支持語音解析等固定參數(shù)版本穩(wěn)定之后可以做進(jìn)階升級從識別文本里提取中文數(shù)字。做法是先判斷動作詞命中的位置然后在文本后半段找“二十”“五十”“一百”這種數(shù)字表達(dá)。一個(gè)比較樸素的轉(zhuǎn)換函數(shù)def chinese_to_number(text: str) - int: 極簡中文數(shù)字解析只處理 0~999適用于常見距離。 digits { 零: 0, 一: 1, 二: 2, 兩: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, } units {十: 10, 百: 100} total 0 temp 0 for char in text: if char in digits: temp digits[char] elif char in units: if char 十 and temp 0: temp 1 total temp * units[char] temp 0 total temp return total這種解析器不適合做復(fù)雜的金額轉(zhuǎn)換但應(yīng)付“前進(jìn)五十”“左轉(zhuǎn)一百八”這種口語足夠。然后在build_tello_command里把最初的固定距離替換成解析結(jié)果即可。不過我還是要提醒語音識別到“一百八十”時(shí)中文數(shù)字解析很容易出錯(cuò)實(shí)際測試時(shí)一定要先在日志里看清楚識別結(jié)果再做映射。5. 實(shí)測中的避坑記錄延遲、誤識別、嘈雜環(huán)境、安全兜底5.1 識別延遲比我預(yù)想的小但代碼阻塞問題卻很大我最初寫版本時(shí)語音識別是在主循環(huán)里同步執(zhí)行的。也就是說麥克風(fēng)采集到音頻之后要等 Vosk 返回結(jié)果再繼續(xù)讀下一段音頻。如果模型識別稍慢麥克風(fēng)緩沖區(qū)里的后續(xù)語音就會被丟棄直接后果是你說“起飛”它識別出來了但你說完“降落”它可能正好處于識別中的阻塞狀態(tài)漏掉了。解決方式就是我在代碼里用audio_queue異步采集。麥克風(fēng)回調(diào)函數(shù)把音頻流不斷放入隊(duì)列主循環(huán)再從隊(duì)列里取數(shù)據(jù)。這樣即使某次識別慢一點(diǎn)音頻不會丟只是處理順序稍微延后。這套生產(chǎn)者-消費(fèi)者模型在很多實(shí)時(shí)識別場景里都能用建議記下來。5.2 關(guān)鍵詞沖突比誤識別更隱蔽我在測試中遇到一個(gè)很有意思的誤觸發(fā)我本來想說“起飛”但模型識別成了“集體的起飛”或者“請飛”。后來排查發(fā)現(xiàn)問題不在識別而在我的關(guān)鍵詞匹配用了簡單的if word in text而中文分詞把“起飛”嵌在了一個(gè)長句里。對策是縮小匹配窗口。當(dāng)識別文本很短時(shí)不擔(dān)心但如果文本較長我會只取前 4 個(gè)字作為有效指令區(qū)域。比如“起飛謝謝”這種長尾干擾直接裁掉前兩個(gè)詞再判斷。另一個(gè)策略是增加置信度過濾Vosk 會返回多個(gè) token 和置信度信息我實(shí)際項(xiàng)目里要求命中關(guān)鍵詞的置信度不低于 0.6。5.3 飛行環(huán)境的安全兜底無人機(jī)語音控制最怕的就是“說了不該說的詞”。比如你在聊天提到“左轉(zhuǎn)走”無人機(jī)恰好聽到了結(jié)果突然原地轉(zhuǎn)過機(jī)身。這雖然不致命但如果旁邊有人真的很嚇人。所以我的安全設(shè)計(jì)有三層程序只在非對話場景下運(yùn)行起飛后關(guān)掉無關(guān)音源。所有平移距離參數(shù)限制在 30~60 厘米之間不讓單次動作幅度過大。緊急“降落”關(guān)鍵詞必須優(yōu)先匹配而且響應(yīng)時(shí)間要比其他指令更快。實(shí)際項(xiàng)目里我還把“急?!眴为?dú)拉出來不經(jīng)過完整語音長句判斷直接在識別結(jié)果里查這個(gè)子串。只要出現(xiàn)“急?!薄敖德洹绷⒖贪l(fā)送land然后下一次動作前強(qiáng)制冷卻 5 秒。這種特殊待遇在異常情況下非常重要。5.4 不要開著螺旋槳測試語音這可能是最容易被忽略的一點(diǎn)。我第一輪測試時(shí)把無人機(jī)放在地面已經(jīng)處于起飛狀態(tài)但還沒升起來然后自己對著麥克風(fēng)反復(fù)說“旋轉(zhuǎn)”“旋轉(zhuǎn)”結(jié)果無人機(jī)突然原地輕微轉(zhuǎn)了一下差點(diǎn)從桌面上掃下去。后來我把測試流程改成了“地面半托管模式”先讓無人機(jī)在手里拿著或者放在開闊地面然后測試語音識別部分只打印日志不實(shí)際發(fā)送飛行指令。等打印日志穩(wěn)定了再真機(jī)起飛測試。這種做法雖然慢一點(diǎn)但能避免很多螺旋槳事故。6. 還能怎么玩語音回傳、自定義指令和后續(xù)擴(kuò)展6.1 結(jié)合命令回傳狀態(tài)信息Tello EDU 不是只會單向接收指令它也會返回傳感器數(shù)據(jù)、電池電量、姿態(tài)信息。你可以把這些信息轉(zhuǎn)成語音回傳比如識別到“電量”時(shí)請求battery?然后用本地語音合成說“當(dāng)前電量百分之八十”。這樣一個(gè)簡單的語音問答系統(tǒng)就出來了也適合拿來做課堂演示。我建議把 UDP 狀態(tài)輪詢和數(shù)據(jù)解析單獨(dú)封裝成一個(gè)函數(shù)不要和飛行動作混在一起。比如def query_battery() - str: resp send_command(battery?) try: return f當(dāng)前電量 {int(resp)}% except ValueError: return 電量查詢失敗6.2 自定義手勢和特殊動作語音除了控制位移還可以控制翻轉(zhuǎn)、相機(jī)拍照、拋飛之類的高級動作。Tello EDU 支持flip指令說“翻個(gè)跟頭”讓無人機(jī)向前翻演示效果非??帷5D(zhuǎn)對空間有要求前后左右至少留出 1.5 米余量而且要確認(rèn)電池電量足夠。翻轉(zhuǎn)過低電量容易掉下來。在指令映射里加一條keyword_map[翻跟頭] flip f keyword_map[左翻] flip l注意翻轉(zhuǎn)動作不是懸停指令執(zhí)行時(shí)會有一個(gè)明顯的高度變化不適合在桌面上空測試。6.3 從語音控制升級到情景自動化等語音控制穩(wěn)定后更進(jìn)階的方向是情景自動化。比如利用 Tello EDU 的編程接口讓無人機(jī)在識別到“開始巡檢”后按照預(yù)設(shè)路徑飛行同時(shí)在每個(gè)航點(diǎn)語音播報(bào)“到達(dá)一號位置”。這種組合非常適合教育場景學(xué)生可以在一套系統(tǒng)里同時(shí)接觸語音識別、路徑規(guī)劃、UDP 通信和狀態(tài)回傳。我最后的體會是語音控制無人機(jī)最難的不是識別模型而是如何不讓識別誤差變成飛行事故。項(xiàng)目里真正花時(shí)間的部分其實(shí)是設(shè)計(jì)關(guān)鍵詞沖突規(guī)避、距離限制和急停優(yōu)先級。把這些東西做好之后你會發(fā)現(xiàn)基于 Python 的語音控制 Tello EDU 不僅酷而且可以成為一個(gè)非常不錯(cuò)的 AIoT 教學(xué)與實(shí)驗(yàn)平臺。