
スマートグラス Even G2 と Amazon Transcribe Streaming で会話中の 5・7・5 を拾ってみた
はじめに
以前から気になっていたスマートグラスの Even G2 を、ついに購入しました。Even G2 は、左右のレンズに緑色の文字や図形を重ねて表示できる眼鏡です。


実際にかけると、視界を遮らずに必要な情報だけが前方へ浮かぶように見えます。

見え方としてはだいたいこんな感じ

眼鏡をかけていない人からは見えず、眼鏡を外して正面から撮るとギリギリ写る程度
基本的な操作は、眼鏡の横にあるタッチパネルとスマートフォンの Even Realities App から行います。

タッチパネルは両側にある
今回は試しに、会話の中に偶然現れた 5・7・5 を検出し、Even G2 へ表示するモックを作ってみることにしました。
Even G2 の音声を Amazon Transcribe Streaming へ送り、確定した文字起こしを逐次処理します。「今日もまた静かな朝に風が吹く」という発話から、次の 5・7・5 を検出できました。
5・7・5を検出
今日もまた
静かな朝に
風が吹く

これで今日から俳人だ
検証環境
- AWS リージョン:
ap-northeast-1 - AWS SAM CLI:
1.166.1 - Node.js:
24.14.0 - Lambda ランタイム:
nodejs22.x @evenrealities/even_hub_sdk:0.0.14@faanau/kuromoji:0.2.1- Even Realities App:
2.2.9以降
参考資料
- Even Hub Documentation
- Even Hub のテスト方法
- Amazon Transcribe のストリーミング音声
- Amazon Transcribe の WebSocket 設定
構成
AWS 側には API Gateway REST API と Lambda を置きます。同じ Lambda が、Amazon Transcribe の署名付き WebSocket URL を発行する POST /session と、文字起こしを判定する POST /detect を処理します。会話データを保存するリソースは作りません。
実装
ストリーミング文字起こし
Even G2 から取得した 16 kHz、16 bit、モノラルの PCM は、100 ms、3,200 byte ごとのチャンクへそろえて送信しました。(Amazon Transcribe の推奨範囲は 50~200 ms)
100 ms / 1,000 × 16,000 Hz × 2 byte = 3,200 byte
Lambda は接続開始期限を 60 秒にした署名付き URL を発行します。
アプリでは Smithy の EventStream コーデックを使い、PCM を AudioEvent に、応答の TranscriptEvent を文字起こしへ変換しました。
5・7・5 の判定
日本語の文字数とモーラ数は一致しません。そこで Lambda では、Kuromoji で確定文字列を形態素に分け、読みを取得することにしました。小書きの仮名は単独で数えず、ッ、ン、ー はそれぞれ 1 モーラ、句読点と空白は 0 モーラとします。形態素の境界からモーラ数を加算し、累積値が 5 → 12 → 17 に達した列だけを候補にして 5・7・5 と判定します。
デプロイと実機への導入
依存関係の導入と自動テストの後、AWS SAM でデプロイしました。AllowedSourceIp にはスマートフォンの接続元となるグローバル IPv4 アドレスを /32 で指定しました。
npm ci --prefix backend
npm ci --prefix app
npm test --prefix backend
npm test --prefix app
sam build --template-file infra/template.yaml
sam validate --template-file .aws-sam/build/template.yaml --lint
sam deploy `
--template-file .aws-sam/build/template.yaml `
--stack-name even-g2-streaming-575-demo `
--region ap-northeast-1 `
--capabilities CAPABILITY_IAM `
--parameter-overrides AllowedSourceIp=203.0.113.10/32 `
--resolve-s3 `
--no-confirm-changeset
CloudFormation の出力から API URL を取得し、アプリをパッケージ化しました。
$apiUrl = aws cloudformation describe-stacks `
--stack-name even-g2-streaming-575-demo `
--region ap-northeast-1 `
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" `
--output text
Set-Location app
npm run configure -- --api-url $apiUrl
npm run build
npx evenhub pack app.json dist -o 575-detector.ehpk --sdk-ver 0.0.14
生成した 575-detector.ehpk を Even Hub の My projects からアップロードし、Private build として登録しました。

その後 Even Realities App の Even Hub > マイプラグイン > 開発者ハブ > 開発中 > Streaming 575 からインストールしました。

検証
アプリを開くと、スマートフォンに「会話の5・7・5検出」画面が表示されました。

眼鏡には次の案内が表示されました。
スマートフォンで開始
音声はAWSへ送信します
スマートフォンで「開始」を押すと、眼鏡では「接続中」と表示され、すぐに「録音中」へ変わりました。
「これは文字起こしのテストです。」と話すと、スマートフォンと眼鏡の両方に表示されました。
次に、「今日もまた静かな朝に風が吹く」と自然に続けて発話しました。スマートフォンには「今日もまた/静かな朝に/風が吹く」が 1 件表示されました。

眼鏡には次の 4 行が表示されました。
5・7・5を検出
今日もまた
静かな朝に
風が吹く

今後の展望
今回は 5・7・5 検出という遊びの道具を作りましたが、Even G2 を実際に使うと、スマートグラスはまだまだ試せることが多いと感じました。次は、今回確認した PCM 取得と確定文字起こしの経路を生かし、会話の進行に合わせて要点を更新する仕組みを作ってみるつもりです。本記事が、スマートグラスの導入を検討されている方の参考になれば幸いです。
付録
本記事の実装で中心となるコードを掲載します。
AWS 側の構成
infra/template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Even G2 streaming transcription and 5-7-5 detection mock
Parameters:
AllowedSourceIp:
Type: String
Description: Global IPv4 address allowed to invoke the API, in /32 CIDR notation
AllowedPattern: '^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/32$'
Globals:
Function:
Runtime: nodejs22.x
Architectures:
- x86_64
MemorySize: 512
Timeout: 10
Resources:
DetectorApi:
Type: AWS::Serverless::Api
Properties:
Name: !Sub '${AWS::StackName}-api'
StageName: demo
OpenApiVersion: 3.0.1
EndpointConfiguration: REGIONAL
Cors:
AllowOrigin: "'*'"
AllowMethods: "'POST,OPTIONS'"
AllowHeaders: "'Content-Type'"
Auth:
ResourcePolicy:
IpRangeWhitelist:
- !Ref AllowedSourceIp
DetectorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Sub '${AWS::StackName}-api'
Description: Creates Transcribe sessions and detects 5-7-5 mora sequences
CodeUri: ../backend/
Handler: src/handler.handler
Policies:
- AWSLambdaBasicExecutionRole
- Version: '2012-10-17'
Statement:
- Sid: StartTranscribeWebSocket
Effect: Allow
Action: transcribe:StartStreamTranscriptionWebSocket
Resource: '*'
Events:
CreateSession:
Type: Api
Properties:
RestApiId: !Ref DetectorApi
Path: /session
Method: post
Detect575:
Type: Api
Properties:
RestApiId: !Ref DetectorApi
Path: /detect
Method: post
DetectorFunctionLogGroup:
Type: AWS::Logs::LogGroup
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
LogGroupName: !Sub '/aws/lambda/${DetectorFunction}'
RetentionInDays: 1
Outputs:
ApiUrl:
Description: Base URL for the Even Hub app
Value: !Sub 'https://${DetectorApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/demo'
FunctionName:
Description: Lambda function name
Value: !Ref DetectorFunction
LogGroupName:
Description: CloudWatch Logs log group created by this stack
Value: !Ref DetectorFunctionLogGroup
5・7・5 の判定
backend/src/detect-575.mjs
function hasKnownReading(token) {
return (
typeof token.reading === 'string' &&
Number.isInteger(token.moras) &&
token.moras >= 0
)
}
function joinTokens(tokens, field) {
return tokens.map(token => token[field]).join('')
}
function candidateFrom(tokens, boundaries, resultId, startIndex, endIndex) {
const ranges = [
[startIndex, boundaries[0]],
[boundaries[0] + 1, boundaries[1]],
[boundaries[1] + 1, boundaries[2]],
]
const lines = ranges.map(([start, end]) =>
joinTokens(tokens.slice(start, end + 1), 'surface'),
)
const readings = ranges.map(([start, end]) =>
joinTokens(tokens.slice(start, end + 1), 'reading'),
)
return {
id: `${resultId}:${startIndex}:${endIndex}:${readings.join('|')}`,
lines,
readings,
}
}
function findCandidates(tokens, resultId) {
const candidates = []
for (let startIndex = 0; startIndex < tokens.length; startIndex += 1) {
let total = 0
const boundaries = []
for (let endIndex = startIndex; endIndex < tokens.length; endIndex += 1) {
total += tokens[endIndex].moras
const boundaryIndex = [5, 12, 17].indexOf(total)
if (boundaryIndex >= 0) {
boundaries[boundaryIndex] = endIndex
}
if (total === 17) {
if (boundaries.length === 3 && tokens[endIndex].isNew) {
candidates.push(
candidateFrom(tokens, boundaries, resultId, startIndex, endIndex),
)
}
break
}
const nextBoundary = [5, 12, 17][boundaries.length]
if (total > nextBoundary) break
}
}
return candidates
}
function trailingContext(tokens) {
let startIndex = tokens.length
let total = 0
for (let index = tokens.length - 1; index >= 0; index -= 1) {
const nextTotal = total + tokens[index].moras
if (nextTotal > 16) break
total = nextTotal
startIndex = index
}
return tokens
.slice(startIndex)
.map(({ isNew: _isNew, ...token }) => token)
}
export function detect575({ context, newTokens, resultId }) {
const combined = [
...context.map(token => ({ ...token, isNew: false })),
...newTokens.map(token => ({ ...token, isNew: true })),
]
const lastUnknownIndex = combined.findLastIndex(
token => !hasKnownReading(token),
)
const usable = combined.slice(lastUnknownIndex + 1)
return {
context: trailingContext(usable),
candidates: findCandidates(usable, resultId),
}
}
backend/src/japanese.mjs
import { fileURLToPath } from 'node:url'
const IGNORED_CHARACTERS = /[\s、。!?,.!?]/u
const SMALL_KANA = /[ャュョァィゥェォヮゃゅょぁぃぅぇぉゎ]/u
const KANA_ONLY = /^[ぁ-ゖァ-ヺー]+$/u
export function countMoras(reading) {
let count = 0
for (const character of reading) {
if (!IGNORED_CHARACTERS.test(character) && !SMALL_KANA.test(character)) {
count += 1
}
}
return count
}
function normalizeReading(reading) {
return Array.from(reading, character => {
const codePoint = character.codePointAt(0)
if (codePoint >= 0x3041 && codePoint <= 0x3096) {
return String.fromCodePoint(codePoint + 0x60)
}
return character
}).join('')
}
export function toAnalyzedTokens(features) {
return features.map(feature => {
const readingSource =
feature.pronunciation ||
feature.reading ||
(KANA_ONLY.test(feature.surface_form) ? feature.surface_form : null)
const reading = readingSource ? normalizeReading(readingSource) : null
return {
surface: feature.surface_form,
reading,
moras: reading === null ? null : countMoras(reading),
}
})
}
export async function createJapaneseTokenizer(options = {}) {
const { builder } = await import('@faanau/kuromoji')
const dicPath =
options.dicPath ??
fileURLToPath(
new URL('../node_modules/@faanau/kuromoji/dict/', import.meta.url),
)
const tokenizer = await new Promise((resolve, reject) => {
builder({ dicPath }).build((error, builtTokenizer) => {
if (error) reject(error)
else resolve(builtTokenizer)
})
})
return text => toAnalyzedTokens(tokenizer.tokenize(text))
}
ストリーミング文字起こし
app/src/transcribe/eventstream.ts
import { EventStreamCodec } from '@smithy/core/event-streams'
import { fromUtf8, toUtf8 } from '@smithy/core/serde'
export interface TranscriptResult {
resultId: string
isPartial: boolean
transcript: string
}
export type TranscribeMessage =
| { kind: 'transcript'; results: TranscriptResult[] }
| { kind: 'exception'; type: string; message: string }
const codec = new EventStreamCodec(toUtf8, fromUtf8)
export function encodeAudioEvent(pcm: Uint8Array): Uint8Array {
return codec.encode({
headers: {
':message-type': { type: 'string', value: 'event' },
':event-type': { type: 'string', value: 'AudioEvent' },
':content-type': { type: 'string', value: 'application/octet-stream' },
},
body: pcm,
})
}
function stringHeader(
headers: ReturnType<EventStreamCodec['decode']>['headers'],
name: string,
): string | undefined {
const header = headers[name]
return header?.type === 'string' ? header.value : undefined
}
export function decodeTranscribeMessage(data: ArrayBufferView): TranscribeMessage {
const decoded = codec.decode(data)
const messageType = stringHeader(decoded.headers, ':message-type')
const body = JSON.parse(toUtf8(decoded.body)) as unknown
if (messageType === 'exception') {
const value = body as { Message?: unknown; message?: unknown }
const message = value.Message ?? value.message
return {
kind: 'exception',
type: stringHeader(decoded.headers, ':exception-type') ?? 'TranscribeException',
message: typeof message === 'string' ? message : 'Amazon Transcribeでエラーが発生しました。',
}
}
const payload = body as {
Transcript?: {
Results?: Array<{
ResultId?: unknown
IsPartial?: unknown
Alternatives?: Array<{ Transcript?: unknown }>
}>
}
}
const results = payload.Transcript?.Results ?? []
return {
kind: 'transcript',
results: results.flatMap((result) => {
const transcript = result.Alternatives?.[0]?.Transcript
if (
typeof result.ResultId !== 'string' ||
typeof result.IsPartial !== 'boolean' ||
typeof transcript !== 'string'
) {
return []
}
return [{ resultId: result.ResultId, isPartial: result.IsPartial, transcript }]
}),
}
}
app/src/transcribe/pcm-chunker.ts
export class PcmChunker {
private pending = new Uint8Array()
constructor(private readonly chunkSize = 3200) {
if (chunkSize <= 0 || chunkSize % 2 !== 0) {
throw new Error('chunkSizeは正の偶数である必要があります。')
}
}
push(input: Uint8Array): Uint8Array[] {
if (input.byteLength % 2 !== 0) {
throw new Error('16bit PCMの入力は偶数バイトである必要があります。')
}
const joined = new Uint8Array(this.pending.byteLength + input.byteLength)
joined.set(this.pending)
joined.set(input, this.pending.byteLength)
const chunks: Uint8Array[] = []
let offset = 0
while (joined.byteLength - offset >= this.chunkSize) {
chunks.push(joined.slice(offset, offset + this.chunkSize))
offset += this.chunkSize
}
this.pending = joined.slice(offset)
return chunks
}
flush(): Uint8Array {
const remainder = this.pending
this.pending = new Uint8Array()
return remainder
}
}
app/src/transcribe/transcribe-client.ts
import type { TranscribeSession } from '../api/detector-api'
import { decodeTranscribeMessage, encodeAudioEvent, type TranscriptResult } from './eventstream'
import { PcmChunker } from './pcm-chunker'
const OPEN = 1
const CLOSED = 3
export interface WebSocketLike {
binaryType: BinaryType
readyState: number
onopen: (() => void) | null
onmessage: ((event: { data: ArrayBuffer }) => void) | null
onerror: (() => void) | null
onclose: (() => void) | null
send(data: Uint8Array): void
close(): void
}
interface TranscribeClientOptions {
createSession: () => Promise<TranscribeSession>
onResult: (result: TranscriptResult) => void | Promise<void>
onFatal: (error: Error) => void
onFinishTimeout?: () => void
socketFactory?: (url: string) => WebSocketLike
}
type State = 'idle' | 'connecting' | 'open' | 'finishing' | 'closed'
export class TranscribeClient {
private readonly chunker = new PcmChunker(3200)
private readonly socketFactory: (url: string) => WebSocketLike
private socket?: WebSocketLike
private state: State = 'idle'
private finishPromise?: Promise<void>
private resolveFinish?: () => void
private finishTimer?: ReturnType<typeof setTimeout>
private failed = false
private readonly pendingResultTasks = new Set<Promise<void>>()
constructor(private readonly options: TranscribeClientOptions) {
this.socketFactory =
options.socketFactory ?? ((url) => new WebSocket(url) as unknown as WebSocketLike)
}
async start(): Promise<void> {
if (this.state !== 'idle') throw new Error('文字起こしはすでに開始されています。')
this.state = 'connecting'
const session = await this.options.createSession()
if (Date.parse(session.connectBefore) <= Date.now()) {
this.state = 'closed'
throw new Error('文字起こし接続URLの有効期限が切れています。')
}
const socket = this.socketFactory(session.url)
this.socket = socket
socket.binaryType = 'arraybuffer'
return new Promise<void>((resolve, reject) => {
socket.onopen = () => {
this.state = 'open'
resolve()
}
socket.onmessage = (event) => void this.handleMessage(event.data)
socket.onerror = () => {
const error = new Error('Amazon Transcribeとの接続でエラーが発生しました。')
if (this.state === 'connecting') reject(error)
this.fail(error)
}
socket.onclose = () => {
const previousState = this.state
this.state = 'closed'
this.clearFinishTimer()
void this.resolveFinishAfterPendingResults()
if (previousState === 'connecting') {
reject(new Error('Amazon Transcribeへ接続できませんでした。'))
} else if (previousState === 'open' && !this.failed) {
this.fail(new Error('Amazon Transcribeとの接続が途中で閉じられました。'))
}
}
})
}
sendPcm(pcm: Uint8Array): void {
if (this.state !== 'open' || this.socket?.readyState !== OPEN) return
for (const chunk of this.chunker.push(pcm)) {
this.socket.send(encodeAudioEvent(chunk))
}
}
finish(): Promise<void> {
if (this.finishPromise !== undefined) return this.finishPromise
if (this.state !== 'open' || this.socket?.readyState !== OPEN) {
return Promise.resolve()
}
this.state = 'finishing'
this.finishPromise = new Promise<void>((resolve) => {
this.resolveFinish = resolve
this.finishTimer = setTimeout(() => {
this.options.onFinishTimeout?.()
this.closeSocket()
}, 5000)
})
const remainder = this.chunker.flush()
if (remainder.byteLength > 0) this.socket.send(encodeAudioEvent(remainder))
this.socket.send(encodeAudioEvent(new Uint8Array()))
return this.finishPromise
}
private async handleMessage(data: ArrayBuffer): Promise<void> {
try {
const message = decodeTranscribeMessage(new Uint8Array(data))
if (message.kind === 'exception') {
this.fail(new Error(`${message.type}: ${message.message}`))
return
}
await Promise.all(message.results.map((result) => this.trackResult(result)))
if (this.state === 'finishing' && message.results.some((result) => !result.isPartial)) {
this.closeSocket()
}
} catch {
this.fail(new Error('Amazon Transcribeの応答を解析できませんでした。'))
}
}
private trackResult(result: TranscriptResult): Promise<void> {
const task = Promise.resolve(this.options.onResult(result)).catch((error: unknown) => {
this.fail(error instanceof Error ? error : new Error('確定結果の処理に失敗しました。'))
})
this.pendingResultTasks.add(task)
void task.finally(() => this.pendingResultTasks.delete(task))
return task
}
private async resolveFinishAfterPendingResults(): Promise<void> {
await Promise.all([...this.pendingResultTasks])
this.resolveFinish?.()
}
private fail(error: Error): void {
if (!this.failed) {
this.failed = true
this.options.onFatal(error)
}
this.closeSocket()
}
private closeSocket(): void {
this.clearFinishTimer()
if (this.socket !== undefined && this.socket.readyState !== CLOSED) {
this.socket.close()
} else {
this.resolveFinish?.()
}
}
private clearFinishTimer(): void {
if (this.finishTimer !== undefined) {
clearTimeout(this.finishTimer)
this.finishTimer = undefined
}
}
}






