iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0

一、前言

昨天看了俠客 Google Cloud Speech-to-Text 的實測身手,
今天回到 App,看看錄音怎麼送上山、文字怎麼帶回來。

請俠客聽寫,有三種 委託方式:

方式 什麼時候送 什麼時候拿到文字 適合
同步辨識 錄完,整段一次送出 送出之後,一次拿到完整結果 1 分鐘內的短錄音
串流辨識 一邊錄,一邊送 說話的同時,陸續拿到 即時、邊說邊辨識
批次辨識 錄音先放到 Cloud Storage 處理完成後,再取回結果 1 分鐘以上的長錄音

健口操和小遊戲的錄音都很短,這次只用到同步、串流兩種。

這兩種方式,各有要解決的問題:

  • 錄好的一段話,要怎麼交給鏢局、請俠客寫成文字?
  • 想要邊說邊出,鏢局一趟一趟押鏢,還來得及嗎?`

今天實作兩種辨識接進 App,讓我們往下看!


二、實作:同步辨識

(一)這趟鏢怎麼走(App ⇄ Cloud Functions ⇄ Cloud STT)

站 做什麼
App 錄好 WAV,轉成 Base64,呼叫函式 recognizeExercise
鏢局(Cloud Functions) 用服務帳戶換令牌,把錄音交給 STT
俠客(Cloud STT) 用 Chirp 3 辨識,回傳文字

Day15 提過:呼叫 Google 自家的服務,
函式可以用自己的身分(服務帳戶)呼叫,連金鑰都不需要。
令牌由鏢局在執行時換來,效期很短;App 從頭到尾不碰任何金鑰。

(二)設定步驟

步驟 1:啟用 Cloud Speech-to-Text API

Google Cloud 的服務預設是關閉的,要先在專案裡啟用。
Firebase 專案本身就是一個 Google Cloud 專案,
到 Google Cloud 主控台選同一個專案就好。

① 開啟 Google Cloud 主控台,左上角選擇 Firebase 的專案

② 「API 與服務」→「程式庫」,搜尋 Cloud Speech-to-Text API

③ 按「啟用」

步驟 2:確認函式的服務帳戶有權限

函式呼叫 STT 時,Google 會先確認:
「是誰在呼叫」、「它有沒有這個權限」。

自己操作 Google Cloud 時,用的是自己的 Google 帳號;
函式在雲端執行時沒有人登入,
所以 Google 會給它一個「機器用的帳號」,叫服務帳戶(service account)。

函式沒特別指定時,用的是專案的 Compute Engine 預設服務帳戶:
〈專案編號〉-compute@developer.gserviceaccount.com

① Google Cloud 主控台 →「IAM 與管理」→「IAM」

② 找到這個服務帳戶,看「角色」欄位

這次的專案裡,它已經有「編輯者」角色,包含 STT 的權限,不用另外設定。

如果沒有(例如公司或學校帳號的專案),按上方的「授予存取權」:

  • 新增主體:填入上面的服務帳戶信箱
  • 角色:搜尋 cloud speech,選「Cloud Speech 用戶端」(roles/speech.client)

再按「儲存」。

小提醒:
「編輯者」的權限,比呼叫 STT 需要的多很多;
Google Cloud 官方建議 換成權限較小的角色。
串流辨識(下一章節),會示範建立只有 STT 權限的專用帳戶。

步驟 3:撰寫函式

這支函式用的是 callable(onCall)。
它是 Cloud Functions 專門給「自家 App」呼叫的方式,
詳見:Day14「三、(一)先決定函式類型:onRequest 還是 onCall」

在 functions/index.js 加上 recognizeExercise,
onCall、HttpsError、logger 沿用 Day14 引入的:

const {applicationDefault} = require("firebase-admin/app");

// Chirp 3 放在 us 多區域,網址的區域要跟模型一致
const location = "us";
const recognizer = `projects/${process.env.GCLOUD_PROJECT}` +
    `/locations/${location}/recognizers/_`;
const sttUrl = `https://${location}-speech.googleapis.com/v2/` +
    `${recognizer}:recognize`;

exports.recognizeExercise = onCall(
    {enforceAppCheck: true},
    async (request) => {
      const audioBase64 = request.data?.audioBase64;
      if (typeof audioBase64 !== "string" || !audioBase64) {
        throw new HttpsError("invalid-argument", "缺少錄音");
      }
      // 用函式的服務帳戶,換一張短效期的令牌(存取權杖)
      const {access_token: token} =
          await applicationDefault().getAccessToken();
      const response = await fetch(sttUrl, {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          config: {
            // WAV 有檔頭,讓 STT 自己讀取樣率、聲道
            autoDecodingConfig: {},
            model: "chirp_3",
            languageCodes: ["cmn-Hant-TW"],
          },
          content: audioBase64,
        }),
      });
      if (!response.ok) {
        // 原因記在 log,App 收到明確的錯誤代碼
        logger.error("STT 呼叫失敗", {status: response.status});
        throw new HttpsError("unavailable", "辨識暫時無法完成");
      }
      const result = await response.json();
      // 每一段結果取第一個候選,接成完整的文字
      const transcript = (result.results ?? [])
          .map((r) => r.alternatives?.[0]?.transcript ?? "")
          .join("");
      logger.info("recognizeExercise 完成");
      return {transcript};
    },
);

幾個重點寫法:

寫法 說明
applicationDefault().getAccessToken() 用函式的服務帳戶換令牌,程式碼裡沒有金鑰
us-speech.googleapis.com、locations/us Chirp 3 在 us 多區域,網址和路徑的區域要一致
recognizers/_ 不另外建立辨識器,設定直接放在請求裡
autoDecodingConfig WAV 有檔頭,讓 STT 自己讀格式
content 錄音的 Base64

步驟 4:部署

firebase deploy --only functions:recognizeExercise

步驟 5:App 端錄音並呼叫

錄音格式:16 kHz、單聲道、16-bit 的 WAV。

原生端錄音(iOS 的 AVAudioEngine、Android 的 AudioRecord),
再透過 MethodChannel 交給 Flutter;這部分篇幅較長,就不開展。

錄好之後,轉成 Base64 呼叫函式:

final functions =
    FirebaseFunctions.instanceFor(region: 'asia-east1');
final callable = functions.httpsCallable('recognizeExercise');

// wavBytes:錄好的 16 kHz、單聲道 WAV
final response = await callable.call({
  'audioBase64': base64Encode(wavBytes),
});
final transcript = response.data['transcript'] as String;

callable 的 SDK 會自動附上 App Check 通行證,不需要另外處理。

步驟 6:確認結果

用 App 和 curl 各呼叫一次:

呼叫方式 結果
App 送出錄音 收到辨識文字
curl,沒有通行證 401 UNAUTHENTICATED

Google Cloud 主控台的 Logs Explorer,也看得到每次呼叫:

以下是 2026-09-30 的實測,錄音是我自己念的繞口令(11.8 秒):

四是四,十是十,十四是十四,四十是四十。

① 俠客交給鏢局的原始回應(STT → 函式):

{
  "results": [
    {
      "alternatives": [
        {"transcript": "4是4,10是10,14是14,40是40。"}
      ],
      "languageCode": "cmn-Hant-TW"
    }
  ],
  "metadata": {
    "totalBilledDuration": "12s",
    "requestId": "6c8e8a2d-……",
    "prompt": "Transcribe the following speech segment ……"
  }
}

欄位說明(Recognize V2):

欄位 說明
results 依錄音順序排列的辨識結果,可能不只一段
alternatives 候選文字,第一個是最可能的
totalBilledDuration 計費秒數:11.8 秒的錄音,算 12 秒
prompt Chirp 3 轉錄時用的指令

prompt 裡有一條「When transcribing numbers, write the digits」:
數字要寫成阿拉伯數字。
Day16 看到 Chirp 3 把「十四」寫成「14」,跟這條指令一致。

② App 拿到的回應(函式 → App):

{"transcript": "4是4,10是10,14是14,40是40。"}

函式只挑出文字交給 App;
計費秒數、prompt 都留在Firebase Functions,不會傳到裝置端。


三、實作:串流辨識

(一)驛站雙向奔赴 (App ⇄ Cloud Run ⇄ Cloud STT)

同步辨識用的 callable,是「一問一答」:
錄完一整段送出,等鏢局回覆一次,就結束了。

串流辨識則像打電話:
線路要一直開著,雙方隨時都能說話。
callable 做不到這件事,
所以這次在山腳下,另外開一間驛站:Cloud Run 上的 WebSocket 服務。

站 做什麼
App 每 100 ms 送一段錄音(PCM)
驛站(Cloud Run) 驗證通行證,把聲音轉給 STT、把文字轉回 App
俠客(Cloud STT) 用 Chirp 3 串流辨識,陸續回傳文字

App 和驛站之間用 WebSocket,驛站和俠客之間用 gRPC。
話一段一段傳上山,文字一段一段帶回來。

俠客回傳的文字,分成兩種:

  • 暫時結果:還在聽,後面可能會改字
  • 定案結果:這一段確定了,不會再改

Cloud Run 支援 WebSocket,不需要額外設定(官方說明)。
但是,Cloud Run 要在程式裡,自己驗證通行證。

(二)設定步驟

步驟 1:建立驛站專用的服務帳戶

① Google Cloud 主控台 →「IAM 與管理」→「服務帳戶」→「建立服務帳戶」

② 名稱填 kenkou-stt-stream

③ 角色選「Cloud Speech 用戶端」(roles/speech.client),按「完成」

步驟 2:撰寫驛站程式

建立 streaming 資料夾,安裝三個套件:

  • ws:架 WebSocket
  • firebase-admin:驗證通行證
  • @google-cloud/speech:呼叫 STT
mkdir streaming && cd streaming
npm init -y
npm install ws firebase-admin @google-cloud/speech

再加上 Dockerfile,告訴 Cloud Run 怎麼建置、啟動:

FROM node:24-bookworm-slim
WORKDIR /service
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY index.js ./
USER node
CMD ["node", "index.js"]

驛站程式 index.js 分成四段:

① 準備:讀設定、建立 STT 用戶端

const http = require("node:http");
const {WebSocketServer} = require("ws");
const {initializeApp} = require("firebase-admin/app");
const {getAppCheck} = require("firebase-admin/app-check");
const {v2} = require("@google-cloud/speech");

const projectId = process.env.GOOGLE_CLOUD_PROJECT;
// 只放行自家 App:iOS、Android 的 App ID,用逗號隔開
const allowedAppIds = process.env.ALLOWED_APP_IDS.split(",");

initializeApp({projectId});
// 在 Cloud Run 上,會自動用服務帳戶的身分呼叫 STT
const speech = new v2.SpeechClient({
  apiEndpoint: "us-speech.googleapis.com",
});
const server = http.createServer();
const wss = new WebSocketServer({noServer: true});
const send = (ws, message) => ws.send(JSON.stringify(message));

② 守衛:升級成 WebSocket 之前,先驗證通行證

server.on("upgrade", async (req, socket, head) => {
  try {
    const token = req.headers["x-firebase-appcheck"];
    if (req.url !== "/stt" || !token) throw new Error();
    const claims = await getAppCheck().verifyToken(token);
    if (!allowedAppIds.includes(claims.appId)) throw new Error();
  } catch (_) {
    socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n");
    return;
  }
  wss.handleUpgrade(req, socket, head,
      (ws) => wss.emit("connection", ws));
});

驗證方式照官方的自建後端驗證 App Check:
從 X-Firebase-AppCheck 標頭取出通行證,交給 verifyToken() 驗證。
這裡多檢查一步 App ID,只放行自家的 iOS、Android App。

③ 開一條 STT 串流:第一則訊息放辨識設定

function openSpeechStream() {
  const stream = speech._streamingRecognize();
  stream.write({
    recognizer: `projects/${projectId}/locations/us/recognizers/_`,
    streamingConfig: {
      config: {
        model: "chirp_3",
        languageCodes: ["cmn-Hant-TW"],
        // 串流送的是沒有檔頭的 PCM,要明講格式
        explicitDecodingConfig: {
          encoding: "LINEAR16",
          sampleRateHertz: 16000,
          audioChannelCount: 1,
        },
      },
      // 還沒定案的暫時結果,也要回傳
      streamingFeatures: {interimResults: true},
    },
  });
  return stream;
}

小提醒:為什麼用 _streamingRecognize()?
套件的 streamingRecognize(),是照 V1 的格式包資料;
V2 要的欄位不一樣(recognizer、audio),所以改用底層的方法,每則訊息自己包。

④ 轉送:聲音往上送、文字往回送

// 定案結果各送一則;暫時結果可能一次好幾段,接起來送一則
function forwardResults(ws, response) {
  let interim = "";
  for (const result of response.results ?? []) {
    const text = result.alternatives?.[0]?.transcript ?? "";
    if (result.isFinal) {
      send(ws, {type: "result", transcript: text, isFinal: true});
    } else {
      interim += text;
    }
  }
  if (interim) {
    send(ws, {type: "result", transcript: interim, isFinal: false});
  }
}

wss.on("connection", (ws) => {
  let stt;
  ws.on("message", (data, isBinary) => {
    if (isBinary) {
      // 錄音片段:原樣轉給 STT
      stt?.write({audio: data});
      return;
    }
    let message;
    try {
      message = JSON.parse(data.toString());
    } catch (_) {
      return ws.close();
    }
    if (message.type === "start" && !stt) {
      stt = openSpeechStream();
      stt.on("data", (response) => forwardResults(ws, response));
      stt.on("error", () => {
        send(ws, {type: "error"});
        ws.close();
      });
      // STT 回完最後的結果,這次辨識就結束
      stt.on("end", () => {
        send(ws, {type: "done"});
        ws.close();
      });
      send(ws, {type: "ready"});
    } else if (message.type === "stop") {
      // 聲音送完了:告訴 STT 不會再有新的聲音
      stt?.end();
    }
  });
  // App 中途斷線:一併關掉 STT 串流
  ws.on("close", () => stt?.cancel());
});

server.listen(process.env.PORT || 8080);

小提醒:
只要還有 WebSocket 連線開著,Cloud Run 就算在使用中、持續計費
(官方說明)。

所以示範專案的驛站,還加了幾道限制:
錄音最多 3 分鐘、閒置 10 秒就斷線、同時最多 2 條連線。
避免連線一直開著,費用跟著累積。

步驟 3:部署到 Cloud Run

Firebase CLI 部署的是 Firebase 的服務(例如 Functions);
一般的 Cloud Run 服務,要用 Google Cloud CLI(gcloud)部署。

不想在本機安裝的話,可以用 Google Cloud 主控台內建的 Cloud Shell,
裡面已經裝好 gcloud。

① 在本機把 streaming 資料夾壓縮成 zip,
不含 node_modules(建置時會重新安裝):

zip -r streaming.zip streaming -x "streaming/node_modules/*"

② 在 Google Cloud 主控台右上角,開啟 Cloud Shell,
從工具列的「⋮」→「上傳」,上傳 streaming.zip

③ 在 Cloud Shell 解壓縮,進到資料夾:

unzip streaming.zip && cd streaming

④ 在資料夾裡建立 env.yaml,放驛站要用的設定:

GOOGLE_CLOUD_PROJECT: kenkou-tw-dev
ALLOWED_APP_IDS: "iOS 的 App ID,Android 的 App ID"

App ID 在 Firebase 主控台 →「專案設定」→「一般」→「你的應用程式」:

⑤ 部署:

SA_EMAIL=kenkou-stt-stream@kenkou-tw-dev.iam.gserviceaccount.com
gcloud run deploy kenkou-stt-stream \
  --source . \
  --region asia-east1 \
  --service-account "$SA_EMAIL" \
  --env-vars-file env.yaml \
  --no-invoker-iam-check \
  --timeout 240 \
  --max-instances 1

第一次部署,會詢問是否啟用需要的 API、建立存放映像檔的儲存庫,
都回答 y 就好。

參數 意思
--source . 用目前資料夾的 Dockerfile 建置映像檔,再部署
--region asia-east1 跟函式一樣放在台灣
--service-account 用步驟 1 建立的專用帳戶
--env-vars-file 專案 ID、放行的 App ID
--no-invoker-iam-check 不檢查 Google Cloud 的帳號身分,手機才連得進來
--timeout 240 一條連線最長 240 秒(預設 5 分鐘)
--max-instances 1 最多 1 個執行個體,示範用,控制費用

小提醒:--no-invoker-iam-check
Cloud Run 預設只讓有 Google Cloud 權限的帳號呼叫,
手機上的 App 沒有這種身分,所以要關掉這道檢查。

關掉之後,任何人都敲得到驛站的門,步驟 2 的守衛一定要在。

部署完成後,在 Cloud Run 主控台點進服務,
「安全性」分頁會顯示「允許公開存取」(官方說明):

步驟 4:App 端連線

驛站的網址:把服務網址的 https 換成 wss,後面加上 /stt。

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:firebase_app_check/firebase_app_check.dart';

const streamUrl = 'wss://你的服務網址/stt';

Future<WebSocket> startStreaming(
  void Function(String text) onText,
) async {
  // ① 帶著通行證連上驛站
  final token = await FirebaseAppCheck.instance.getToken();
  final socket = await WebSocket.connect(
    streamUrl,
    headers: {'X-Firebase-AppCheck': token!},
  );

  // ② 暫時結果「替換」,定案結果「接在後面」
  final ready = Completer<void>();
  var finalText = '';
  socket.listen((message) {
    final data = jsonDecode(message as String);
    switch (data['type']) {
      case 'ready':
        ready.complete();
      case 'result':
        final text = data['transcript'] as String;
        if (data['isFinal'] == true) {
          finalText += text;
          onText(finalText);
        } else {
          onText(finalText + text);
        }
    }
  });

  // ③ 通知驛站開始,收到 ready 再送錄音
  socket.add(jsonEncode({'type': 'start'}));
  await ready.future;
  return socket;
}

錄音時,每收到一段 PCM 就送出;說完了,通知驛站:

// 錄音中:每段約 100 ms
socket.add(pcmChunk);

// 說完了:等最後的結果回來
socket.add(jsonEncode({'type': 'stop'}));

錄音格式跟同步辨識一樣:16 kHz、單聲道、16-bit,
但不包 WAV 檔頭,直接送 PCM。

官方建議每段約 100 ms,
在延遲和效率之間取得平衡(官方說明)。

callable 的 SDK 會自動附上通行證;
自己開的 WebSocket,要用 getToken() 取得,放進標頭。

步驟 5:確認結果

用 curl 和 App 分別連線:

連線方式 結果
curl,沒有通行證 401 Unauthorized
curl,帶一張假的通行證 401 Unauthorized
App(通過 App Check) 連線成功,說話時陸續出現文字

用 curl 測試時,要帶上 WebSocket 升級的標頭:

curl -i --http1.1 https://你的服務網址/stt \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="

實測影片:
https://youtube.com/shorts/u71r7vspqrc?feature=share

串流辨識實測影片


四、調整辨識設定

(一)選模型:模型和區域要對上

每個模型只在特定區域提供,
網址、路徑、模型三個地方的區域要一致:

模型 區域 網址
chirp_3 us us-speech.googleapis.com
chirp_2 asia-southeast1 asia-southeast1-speech.googleapis.com
// 換模型時,區域跟著一起換
const PROVIDERS = {
  chirp_2: {model: "chirp_2", location: "asia-southeast1"},
  chirp_3: {model: "chirp_3", location: "us"},
};
const {model, location} = PROVIDERS[provider];

Chirp 3 正式提供的區域是 us、eu 兩個多區域;
Chirp 2 在 asia-southeast1、us-central1、europe-west4。

(二)語音調整:先告訴模型可能聽到哪些字

Day16 實測的語音調整,寫法是這樣:

config: {
  // ……model、languageCodes 跟前面一樣
  adaptation: {
    phraseSets: [{
      inlinePhraseSet: {
        // boost 可以不設;要設的話範圍 0~20
        boost: 5,
        phrases: ["怕", "踏", "卡", "啦"].map((value) => ({value})),
      },
    }],
  },
},
寫法 說明
phraseSets 提示詞清單,可以放好幾組
inlinePhraseSet 提示詞直接寫在請求裡,不用先另外建立
boost 加權值,越大越容易辨識成提示詞(官方說明)

效果在 Day16 實測過:
加入提示詞後,部分字形更符合預期;

提示詞也可能讓沒說出口的字被辨識出來,
開了之後,要用實際錄音比較看看。

(三)串流專用:什麼時候算說完一句

串流多了 streamingFeatures,可以調整結果怎麼回傳:

streamingFeatures: {
  // 還沒定案的暫時結果,也要回傳
  interimResults: true,
  // 偵測到開始說話、停止說話時,另外通知
  enableVoiceActivityEvents: true,
  // 停頓多久,算是一句說完
  endpointingSensitivity: "ENDPOINTING_SENSITIVITY_STANDARD",
},

endpointingSensitivity 有三種(Chirp 3):

設定 適合 特色
STANDARD(預設) 長句、一般對話 延遲和準確度取得平衡
SHORT 一句話、指令 比預設更快回應
SUPERSHORT 很短的指令、單字 延遲最低,一停下來就定案

健口操的「怕、踏、卡、啦」都是單音,看起來適合 SUPERSHORT;
不過官方建議,只在速度很重要、而且話很短的情況使用。

官方也提醒:反應調得越快,說話中間稍微停頓,就可能被提早截斷。
長輩說話的停頓通常比較多,這點要特別留意。

enableVoiceActivityEvents 打開後,
回應會多出「開始說話」「停止說話」的事件(官方說明);
前面的驛站程式沒有處理這些事件,用不到可以不開。

小提醒:想分辨「誰在說話」?
Chirp 3 的說話者分段標記 Speaker Diarization:
不支援串流辨識,中文只支援簡體,不支援台灣繁中。


五、費用估計

這一套跑起來,會花錢的有三站:

站 服務 怎麼計費
俠客 Cloud STT 錄音長度
驛站 Cloud Run 連線開著的時間
鏢局 Cloud Functions 呼叫次數、執行時間

(一)俠客:照錄音長度計費

V2 的標準模型,每個帳單帳戶每月前 50 萬分鐘,
每分鐘 US$0.016(官方價格):

健口操和小遊戲,都會用到語音辨識。
假設每位使用者每天用 5~10 分鐘:

情境 錄音長度 費用
念一次繞口令 11.8 秒(算 12 秒) US$0.0032
一位使用者,一個月 150~300 分鐘 US$2.4~4.8
100 位使用者,一個月 1.5 萬~3 萬分鐘 US$240~480

串流也是照送進去的錄音長度計算,沒在說話的空檔也算在內:
小遊戲一場 60 秒,串流就開 60 秒。

健口操的練習時間比較好估;
小遊戲玩幾場,就看使用者有多喜歡。
玩得越多,費用也跟著增加。

(二)驛站:連線開著就在計費

Cloud Run 預設只在處理請求時計費;
WebSocket 連線開著,就算是在處理請求。

台灣(asia-east1)的價格與每月免費額度(官方價格):

項目 單價 每月免費額度
CPU US$0.000024/vCPU 秒 18 萬 vCPU 秒
記憶體 US$0.0000025/GiB 秒 36 萬 GiB 秒
請求 US$0.40/百萬次 200 萬次

合計約 US$0.0015,大約是同一分鐘 STT 費用的十分之一。

每月免費的 18 萬 vCPU 秒,換算成一條連線,大約是 3,000 分鐘;
免費額度是整個帳單帳戶共用,每個月重新計算。

(三)鏢局:同步辨識的函式

Blaze 方案的 Cloud Functions,每月免費額度(Firebase 價格):

項目 每月免費額度
呼叫次數 200 萬次
記憶體 40 萬 GB 秒
CPU 20 萬 CPU 秒
對外網路 5 GB

超過之後,照 Google Cloud 的價格計算。

(四)這次實測花了多少

方法 請求次數 用途
v2 Recognize 716 次 同步辨識(Day16、Day17 實測)
v2 StreamingRecognize 23 次 串流辨識(小遊戲、Day17 實測)

再用錄音長度,乘上官方單價估算:

辨識方式 錄音長度 估算
同步辨識 5,259 秒(約 88 分鐘) US$1.40
串流辨識 約 400 秒(約 7 分鐘) US$0.11
合計 約 95 分鐘 約 US$1.5

最後對照主控台「帳單」→「報表」,2026 年 9 月、依服務分組,
我的帳單帳戶是新台幣:

服務 金額(新台幣)
Cloud Speech API 49.73
Cloud Build 0.63
Cloud Run Functions 0.00
Cloud Run 0.00
Cloud Storage、Artifact Registry 0.00
合計 50.36

總共花了新台幣 50.36 元。


六、小結

今天把同步、串流兩種辨識,都接進了 App:

  • 同步辨識:
    App 錄完整段,交給鏢局;
    鏢局用服務帳戶換令牌,呼叫 STT V2 的 Recognize,一次最多 1 分鐘。
  • 串流辨識:
    callable 不能持續上傳聲音,所以另開 Cloud Run 驛站;
    App 用 WebSocket 每 100 ms 送一段 PCM,驛站用 gRPC 轉給 STT。
  • 守衛:
    自己開的驛站,要在程式裡用 verifyToken() 驗證,
    再關掉 IAM 檢查,讓手機連得進來。
  • 權限:
    函式沿用預設服務帳戶;驛站用只有 STT 權限的專用帳戶。
  • 辨識設定:
    模型和區域要對上;語音調整可以提供提示詞;
    串流可以調整多快定案,但說話停頓多時,要留意被提早截斷。
  • 費用:
    STT 照錄音長度計費,每分鐘 US$0.016;
    驛站連線開著就計費,大約是 STT 的十分之一。

錄完再問,交給鏢局;邊說邊聽,交給驛站!


七、心得:雲端還是地端?

Google Cloud Speech to Text 系列,到今天告一段落~

Chirp 3 相較於過往模型,進步很明顯。

不過,也有幾個點,目前沒辦法完全滿足健口動一動的需求:

  • 重複單音計次:
    念八次只留下五個字;Google 已經回覆這是 Chirp 2、Chirp 3 刻意的保護機制,
    避免模型在靜音或模糊的聲音裡無限重複,目前沒有參數可以調整
    (Issue #567868709)
  • 錯讀紀錄:念錯時,辨識文字沒有完整保留實際念法
  • 穩定性:單音偶爾會混淆,例如「踏」辨識成「怕」
  • 長期費用:
    假設每天用 5~10 分鐘,每位使用者每月約 US$2.4~4.8;
    小遊戲玩幾場,也很難事先抓準

以個人開發來說,長期營運費用,可能會是一筆不小的負擔。
所以也會評估開源地端的方式:

比較 雲端(Cloud STT) 地端(開源模型)
費用 照錄音長度計費 沒有按分鐘計費
網路 需要連線 可以離線
錄音 上傳到雲端 留在手機上
辨識品質 這兩篇已實測 要另外實測
維護 模型由 Google 維護 模型大小、手機效能,要自己顧

健口動一動App,最後會選擇雲端還是地端,會再多實測、比較後決定。

選型、營運、維護,都沒有標準答案。
看清楚需求與限制,找出最適合的做法,也是我的重要課題。

最後,
感謝能實作去年沒做成的雲端串流辨識!


八、預告

雲端還是地端,這道題先放在心上;
鐵人賽的下一站,回到 Google AI 的主場。

還記得 Day15 收進保險箱的那把 Gemini 金鑰嗎?
那其實是一張聘書:
府裡要請一位能看、能寫、還能畫的師爺。

明天就來認識這位師爺:Gemini!

感謝有緣看到這邊的你~
希望佛菩薩也祝福你:🌟平安健康 自在順遂🌟
南無觀世音菩薩🍀 南無地藏菩薩🏠 南無阿彌陀佛☀️


上一篇
Flutter : Google Cloud Speech to Text (上)
系列文
Build on Google AI :長者照護 —— 口腔機能訓練 與 延緩認知退化 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言