use-ear 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/hooks/useEar.d.ts +46 -0
- package/dist/hooks/useEar.d.ts.map +1 -0
- package/dist/hooks/useEar.js +273 -0
- package/dist/hooks/usePwa.d.ts +26 -0
- package/dist/hooks/usePwa.d.ts.map +1 -0
- package/dist/hooks/usePwa.js +78 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/types/index.d.ts +42 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +65 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 piro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# use-ear
|
|
2
|
+
|
|
3
|
+
React hooks for wake word detection using Web Speech API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Wake word detection with customizable keywords
|
|
8
|
+
- Multi-language support with per-word language settings
|
|
9
|
+
- Mobile-friendly with audio session keep-alive
|
|
10
|
+
- TypeScript support
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install use-ear
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Basic Usage
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useEar } from "use-ear";
|
|
24
|
+
|
|
25
|
+
function App() {
|
|
26
|
+
const { isListening, isSupported, start, stop, transcript } = useEar({
|
|
27
|
+
wakeWords: ["hello", "hey"],
|
|
28
|
+
onWakeWord: (word, transcript) => {
|
|
29
|
+
console.log(`Detected: ${word}`);
|
|
30
|
+
},
|
|
31
|
+
language: "en-US",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div>
|
|
36
|
+
<button onClick={isListening ? stop : start}>
|
|
37
|
+
{isListening ? "Stop" : "Start"}
|
|
38
|
+
</button>
|
|
39
|
+
<p>Transcript: {transcript}</p>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Multi-language Support
|
|
46
|
+
|
|
47
|
+
You can specify different languages for each wake word:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
useEar({
|
|
51
|
+
wakeWords: [
|
|
52
|
+
{ word: "hello", language: "en-US" },
|
|
53
|
+
{ word: "hey", language: "en-US" },
|
|
54
|
+
{ word: "ヘイ", language: "ja-JP" },
|
|
55
|
+
{ word: "オーケー", language: "ja-JP" },
|
|
56
|
+
],
|
|
57
|
+
onWakeWord: (word) => {
|
|
58
|
+
console.log(`Detected: ${word}`);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The recognition engine rotates through languages automatically.
|
|
64
|
+
|
|
65
|
+
### Stop Words
|
|
66
|
+
|
|
67
|
+
You can specify stop words to automatically stop listening:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
useEar({
|
|
71
|
+
wakeWords: ["hello", "hey"],
|
|
72
|
+
onWakeWord: (word) => {
|
|
73
|
+
console.log(`Detected: ${word}`);
|
|
74
|
+
},
|
|
75
|
+
stopWords: ["stop", "cancel"],
|
|
76
|
+
onStopWord: (word) => {
|
|
77
|
+
console.log(`Stopped by: ${word}`);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Screen Lock (Prevent Sleep)
|
|
83
|
+
|
|
84
|
+
Enable `screenLock` to prevent the screen from sleeping during listening:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
useEar({
|
|
88
|
+
wakeWords: ["hello"],
|
|
89
|
+
onWakeWord: (word) => {
|
|
90
|
+
console.log(`Detected: ${word}`);
|
|
91
|
+
},
|
|
92
|
+
screenLock: true, // Keeps screen awake
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This uses the Wake Lock API to prevent the device from dimming or locking the screen. Useful for hands-free applications where you need continuous listening.
|
|
97
|
+
|
|
98
|
+
## API
|
|
99
|
+
|
|
100
|
+
### Options
|
|
101
|
+
|
|
102
|
+
| Option | Type | Default | Description |
|
|
103
|
+
|--------|------|---------|-------------|
|
|
104
|
+
| `wakeWords` | `(string \| WakeWord)[]` | required | Wake words to detect |
|
|
105
|
+
| `onWakeWord` | `(word: string, transcript: string) => void` | required | Callback when wake word is detected |
|
|
106
|
+
| `stopWords` | `(string \| WakeWord)[]` | `[]` | Words that stop listening when detected |
|
|
107
|
+
| `onStopWord` | `(word: string, transcript: string) => void` | - | Callback when stop word is detected |
|
|
108
|
+
| `language` | `string` | `"ja-JP"` | Default language for speech recognition |
|
|
109
|
+
| `continuous` | `boolean` | `true` | Keep listening after detection |
|
|
110
|
+
| `caseSensitive` | `boolean` | `false` | Case-sensitive matching |
|
|
111
|
+
| `keepAlive` | `boolean` | `true` | Keep audio session alive (for mobile) |
|
|
112
|
+
| `screenLock` | `boolean` | `false` | Prevent screen from sleeping (Wake Lock API) |
|
|
113
|
+
|
|
114
|
+
### Return Values
|
|
115
|
+
|
|
116
|
+
| Value | Type | Description |
|
|
117
|
+
|-------|------|-------------|
|
|
118
|
+
| `isListening` | `boolean` | Currently listening |
|
|
119
|
+
| `isSupported` | `boolean` | Browser supports Web Speech API |
|
|
120
|
+
| `start` | `() => void` | Start listening |
|
|
121
|
+
| `stop` | `() => void` | Stop listening |
|
|
122
|
+
| `error` | `Error \| null` | Error if any |
|
|
123
|
+
| `transcript` | `string` | Last recognized text |
|
|
124
|
+
|
|
125
|
+
## Browser Support
|
|
126
|
+
|
|
127
|
+
Web Speech API is supported in:
|
|
128
|
+
- Chrome (Desktop & Android)
|
|
129
|
+
- Safari (Desktop & iOS)
|
|
130
|
+
- Edge
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Install dependencies
|
|
136
|
+
npm install
|
|
137
|
+
|
|
138
|
+
# Run demo
|
|
139
|
+
npm run dev
|
|
140
|
+
|
|
141
|
+
# Build library
|
|
142
|
+
npm run build:lib
|
|
143
|
+
|
|
144
|
+
# Lint
|
|
145
|
+
npm run lint
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { UseEarOptions, UseEarReturn } from "../types";
|
|
2
|
+
interface SpeechRecognitionResult {
|
|
3
|
+
readonly isFinal: boolean;
|
|
4
|
+
readonly length: number;
|
|
5
|
+
item(index: number): SpeechRecognitionAlternative;
|
|
6
|
+
[index: number]: SpeechRecognitionAlternative;
|
|
7
|
+
}
|
|
8
|
+
interface SpeechRecognitionAlternative {
|
|
9
|
+
readonly transcript: string;
|
|
10
|
+
readonly confidence: number;
|
|
11
|
+
}
|
|
12
|
+
interface SpeechRecognitionResultList {
|
|
13
|
+
readonly length: number;
|
|
14
|
+
item(index: number): SpeechRecognitionResult;
|
|
15
|
+
[index: number]: SpeechRecognitionResult;
|
|
16
|
+
}
|
|
17
|
+
interface SpeechRecognitionEvent extends Event {
|
|
18
|
+
readonly results: SpeechRecognitionResultList;
|
|
19
|
+
readonly resultIndex: number;
|
|
20
|
+
}
|
|
21
|
+
interface SpeechRecognitionErrorEvent extends Event {
|
|
22
|
+
readonly error: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|
|
25
|
+
interface SpeechRecognitionInstance extends EventTarget {
|
|
26
|
+
continuous: boolean;
|
|
27
|
+
interimResults: boolean;
|
|
28
|
+
lang: string;
|
|
29
|
+
onstart: ((this: SpeechRecognitionInstance, ev: Event) => void) | null;
|
|
30
|
+
onresult: ((this: SpeechRecognitionInstance, ev: SpeechRecognitionEvent) => void) | null;
|
|
31
|
+
onerror: ((this: SpeechRecognitionInstance, ev: SpeechRecognitionErrorEvent) => void) | null;
|
|
32
|
+
onend: ((this: SpeechRecognitionInstance, ev: Event) => void) | null;
|
|
33
|
+
start(): void;
|
|
34
|
+
stop(): void;
|
|
35
|
+
abort(): void;
|
|
36
|
+
}
|
|
37
|
+
type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
|
|
38
|
+
declare global {
|
|
39
|
+
interface Window {
|
|
40
|
+
SpeechRecognition?: SpeechRecognitionConstructor;
|
|
41
|
+
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export declare function useEar(options: UseEarOptions): UseEarReturn;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=useEar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useEar.d.ts","sourceRoot":"","sources":["../../src/hooks/useEar.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAiB,MAAM,UAAU,CAAC;AAE3E,UAAU,uBAAuB;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,4BAA4B,CAAC;IAClD,CAAC,KAAK,EAAE,MAAM,GAAG,4BAA4B,CAAC;CAC/C;AAED,UAAU,4BAA4B;IACpC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,UAAU,2BAA2B;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,uBAAuB,CAAC;IAC7C,CAAC,KAAK,EAAE,MAAM,GAAG,uBAAuB,CAAC;CAC1C;AAED,UAAU,sBAAuB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,OAAO,EAAE,2BAA2B,CAAC;IAC9C,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,UAAU,2BAA4B,SAAQ,KAAK;IACjD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,UAAU,yBAA0B,SAAQ,WAAW;IACrD,UAAU,EAAE,OAAO,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,CAAC,CAAC,IAAI,EAAE,yBAAyB,EAAE,EAAE,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,QAAQ,EACJ,CAAC,CAAC,IAAI,EAAE,yBAAyB,EAAE,EAAE,EAAE,sBAAsB,KAAK,IAAI,CAAC,GACvE,IAAI,CAAC;IACT,OAAO,EACH,CAAC,CACC,IAAI,EAAE,yBAAyB,EAC/B,EAAE,EAAE,2BAA2B,KAC5B,IAAI,CAAC,GACV,IAAI,CAAC;IACT,KAAK,EAAE,CAAC,CAAC,IAAI,EAAE,yBAAyB,EAAE,EAAE,EAAE,KAAK,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACrE,KAAK,IAAI,IAAI,CAAC;IACd,IAAI,IAAI,IAAI,CAAC;IACb,KAAK,IAAI,IAAI,CAAC;CACf;AAED,KAAK,4BAA4B,GAAG,UAAU,yBAAyB,CAAC;AAExE,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,iBAAiB,CAAC,EAAE,4BAA4B,CAAC;QACjD,uBAAuB,CAAC,EAAE,4BAA4B,CAAC;KACxD;CACF;AAuFD,wBAAgB,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,YAAY,CAsQ3D"}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
const getSpeechRecognition = () => {
|
|
4
|
+
if (typeof window === "undefined")
|
|
5
|
+
return null;
|
|
6
|
+
return window.SpeechRecognition || window.webkitSpeechRecognition || null;
|
|
7
|
+
};
|
|
8
|
+
const createKeepAlive = () => {
|
|
9
|
+
if (typeof window === "undefined")
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const context = new AudioContext();
|
|
13
|
+
const oscillator = context.createOscillator();
|
|
14
|
+
const gain = context.createGain();
|
|
15
|
+
// ほぼ無音(完全に0だと最適化で停止される可能性がある)
|
|
16
|
+
gain.gain.value = 0.001;
|
|
17
|
+
oscillator.connect(gain);
|
|
18
|
+
gain.connect(context.destination);
|
|
19
|
+
oscillator.start();
|
|
20
|
+
return { context, oscillator, gain };
|
|
21
|
+
}
|
|
22
|
+
catch (_a) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const stopKeepAlive = (keepAlive) => {
|
|
27
|
+
try {
|
|
28
|
+
keepAlive.oscillator.stop();
|
|
29
|
+
keepAlive.oscillator.disconnect();
|
|
30
|
+
keepAlive.gain.disconnect();
|
|
31
|
+
keepAlive.context.close();
|
|
32
|
+
}
|
|
33
|
+
catch (_a) {
|
|
34
|
+
// 既に停止している場合は無視
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const normalizeWakeWords = (wakeWords, defaultLanguage) => {
|
|
38
|
+
return wakeWords.map((w) => typeof w === "string" ? { word: w, language: defaultLanguage } : w);
|
|
39
|
+
};
|
|
40
|
+
const getUniqueLanguages = (wakeWords) => {
|
|
41
|
+
return [...new Set(wakeWords.map((w) => w.language))];
|
|
42
|
+
};
|
|
43
|
+
// Wake Lock API
|
|
44
|
+
const requestWakeLock = async () => {
|
|
45
|
+
if (typeof navigator === "undefined" || !("wakeLock" in navigator)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return await navigator.wakeLock.request("screen");
|
|
50
|
+
}
|
|
51
|
+
catch (_a) {
|
|
52
|
+
// Wake Lock APIがサポートされていない、または権限がない場合
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const releaseWakeLock = async (wakeLock) => {
|
|
57
|
+
if (wakeLock) {
|
|
58
|
+
try {
|
|
59
|
+
await wakeLock.release();
|
|
60
|
+
}
|
|
61
|
+
catch (_a) {
|
|
62
|
+
// 既に解放されている場合は無視
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
export function useEar(options) {
|
|
67
|
+
const { wakeWords, onWakeWord, stopWords = [], onStopWord, continuous = true, language = "ja-JP", caseSensitive = false, keepAlive = true, screenLock = false, } = options;
|
|
68
|
+
const [isListening, setIsListening] = useState(false);
|
|
69
|
+
const [hasMounted, setHasMounted] = useState(false);
|
|
70
|
+
const [error, setError] = useState(null);
|
|
71
|
+
const [transcript, setTranscript] = useState("");
|
|
72
|
+
const recognitionRef = useRef(null);
|
|
73
|
+
const keepAliveRef = useRef(null);
|
|
74
|
+
const wakeLockRef = useRef(null);
|
|
75
|
+
const onWakeWordRef = useRef(onWakeWord);
|
|
76
|
+
const onStopWordRef = useRef(onStopWord);
|
|
77
|
+
const languageIndexRef = useRef(0);
|
|
78
|
+
const shouldContinueRef = useRef(false);
|
|
79
|
+
// ワードを正規化して言語リストを取得
|
|
80
|
+
const normalizedWakeWords = normalizeWakeWords(wakeWords, language);
|
|
81
|
+
const normalizedStopWords = normalizeWakeWords(stopWords, language);
|
|
82
|
+
const allWords = [...normalizedWakeWords, ...normalizedStopWords];
|
|
83
|
+
const languages = getUniqueLanguages(allWords);
|
|
84
|
+
const normalizedWakeWordsRef = useRef(normalizedWakeWords);
|
|
85
|
+
const normalizedStopWordsRef = useRef(normalizedStopWords);
|
|
86
|
+
const languagesRef = useRef(languages);
|
|
87
|
+
// 参照を更新
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
onWakeWordRef.current = onWakeWord;
|
|
90
|
+
onStopWordRef.current = onStopWord;
|
|
91
|
+
normalizedWakeWordsRef.current = normalizedWakeWords;
|
|
92
|
+
normalizedStopWordsRef.current = normalizedStopWords;
|
|
93
|
+
languagesRef.current = languages;
|
|
94
|
+
}, [
|
|
95
|
+
onWakeWord,
|
|
96
|
+
onStopWord,
|
|
97
|
+
normalizedWakeWords,
|
|
98
|
+
normalizedStopWords,
|
|
99
|
+
languages,
|
|
100
|
+
]);
|
|
101
|
+
// マウント後にブラウザサポートをチェック(SSRでのhydration mismatchを防ぐ)
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setHasMounted(true);
|
|
104
|
+
}, []);
|
|
105
|
+
// hasMountedがtrueになってから実際のサポート状況を返す
|
|
106
|
+
const isSupported = hasMounted && getSpeechRecognition() !== null;
|
|
107
|
+
const stopRef = useRef(() => { });
|
|
108
|
+
const checkStopWord = useCallback((text) => {
|
|
109
|
+
var _a;
|
|
110
|
+
const normalizedText = caseSensitive ? text : text.toLowerCase();
|
|
111
|
+
for (const stopWord of normalizedStopWordsRef.current) {
|
|
112
|
+
const normalizedWord = caseSensitive
|
|
113
|
+
? stopWord.word
|
|
114
|
+
: stopWord.word.toLowerCase();
|
|
115
|
+
if (normalizedText.includes(normalizedWord)) {
|
|
116
|
+
(_a = onStopWordRef.current) === null || _a === void 0 ? void 0 : _a.call(onStopWordRef, stopWord.word, text);
|
|
117
|
+
stopRef.current();
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}, [caseSensitive]);
|
|
123
|
+
const checkWakeWord = useCallback((text) => {
|
|
124
|
+
const normalizedText = caseSensitive ? text : text.toLowerCase();
|
|
125
|
+
// まずストップワードをチェック
|
|
126
|
+
if (checkStopWord(text)) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
for (const wakeWord of normalizedWakeWordsRef.current) {
|
|
130
|
+
const normalizedWord = caseSensitive
|
|
131
|
+
? wakeWord.word
|
|
132
|
+
: wakeWord.word.toLowerCase();
|
|
133
|
+
if (normalizedText.includes(normalizedWord)) {
|
|
134
|
+
onWakeWordRef.current(wakeWord.word, text);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}, [caseSensitive, checkStopWord]);
|
|
140
|
+
const startWithLanguage = useCallback((lang) => {
|
|
141
|
+
const SpeechRecognitionClass = getSpeechRecognition();
|
|
142
|
+
if (!SpeechRecognitionClass) {
|
|
143
|
+
setError(new Error("SpeechRecognition is not supported in this browser"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// 既存のインスタンスを停止
|
|
147
|
+
if (recognitionRef.current) {
|
|
148
|
+
recognitionRef.current.stop();
|
|
149
|
+
}
|
|
150
|
+
const recognition = new SpeechRecognitionClass();
|
|
151
|
+
recognition.continuous = continuous;
|
|
152
|
+
recognition.interimResults = true;
|
|
153
|
+
recognition.lang = lang;
|
|
154
|
+
recognition.onstart = () => {
|
|
155
|
+
setIsListening(true);
|
|
156
|
+
setError(null);
|
|
157
|
+
};
|
|
158
|
+
recognition.onresult = (event) => {
|
|
159
|
+
const results = Array.from(event.results);
|
|
160
|
+
const latestResult = results[results.length - 1];
|
|
161
|
+
if (latestResult) {
|
|
162
|
+
const text = latestResult[0].transcript;
|
|
163
|
+
setTranscript(text);
|
|
164
|
+
checkWakeWord(text);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
recognition.onerror = (event) => {
|
|
168
|
+
setError(new Error(`Speech recognition error: ${event.error}`));
|
|
169
|
+
setIsListening(false);
|
|
170
|
+
};
|
|
171
|
+
recognition.onend = () => {
|
|
172
|
+
// continuous モードの場合は次の言語で再開
|
|
173
|
+
if (shouldContinueRef.current && continuous) {
|
|
174
|
+
// 次の言語にローテーション
|
|
175
|
+
languageIndexRef.current =
|
|
176
|
+
(languageIndexRef.current + 1) % languagesRef.current.length;
|
|
177
|
+
const nextLang = languagesRef.current[languageIndexRef.current];
|
|
178
|
+
// 少し遅延を入れて再開(ブラウザの制限回避)
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
if (shouldContinueRef.current) {
|
|
181
|
+
startWithLanguage(nextLang);
|
|
182
|
+
}
|
|
183
|
+
}, 100);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
setIsListening(false);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
recognitionRef.current = recognition;
|
|
190
|
+
try {
|
|
191
|
+
recognition.start();
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
setError(e instanceof Error ? e : new Error("Failed to start recognition"));
|
|
195
|
+
}
|
|
196
|
+
}, [continuous, checkWakeWord]);
|
|
197
|
+
const start = useCallback(async () => {
|
|
198
|
+
shouldContinueRef.current = true;
|
|
199
|
+
languageIndexRef.current = 0;
|
|
200
|
+
// keepAliveが有効な場合、オーディオセッションを維持
|
|
201
|
+
if (keepAlive && !keepAliveRef.current) {
|
|
202
|
+
keepAliveRef.current = createKeepAlive();
|
|
203
|
+
}
|
|
204
|
+
// screenLockが有効な場合、画面の自動ロックを防ぐ
|
|
205
|
+
if (screenLock && !wakeLockRef.current) {
|
|
206
|
+
wakeLockRef.current = await requestWakeLock();
|
|
207
|
+
}
|
|
208
|
+
const initialLang = languagesRef.current[0] || language;
|
|
209
|
+
startWithLanguage(initialLang);
|
|
210
|
+
}, [keepAlive, screenLock, language, startWithLanguage]);
|
|
211
|
+
const stop = useCallback(async () => {
|
|
212
|
+
shouldContinueRef.current = false;
|
|
213
|
+
if (recognitionRef.current) {
|
|
214
|
+
recognitionRef.current.stop();
|
|
215
|
+
recognitionRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
if (keepAliveRef.current) {
|
|
218
|
+
stopKeepAlive(keepAliveRef.current);
|
|
219
|
+
keepAliveRef.current = null;
|
|
220
|
+
}
|
|
221
|
+
if (wakeLockRef.current) {
|
|
222
|
+
await releaseWakeLock(wakeLockRef.current);
|
|
223
|
+
wakeLockRef.current = null;
|
|
224
|
+
}
|
|
225
|
+
setIsListening(false);
|
|
226
|
+
}, []);
|
|
227
|
+
// stopRefを更新してcheckStopWordから呼び出せるようにする
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
stopRef.current = stop;
|
|
230
|
+
}, [stop]);
|
|
231
|
+
// Wake Lockはページが非表示になると自動解放されるため、再取得する
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!screenLock)
|
|
234
|
+
return;
|
|
235
|
+
const handleVisibilityChange = async () => {
|
|
236
|
+
if (document.visibilityState === "visible" &&
|
|
237
|
+
shouldContinueRef.current &&
|
|
238
|
+
!wakeLockRef.current) {
|
|
239
|
+
wakeLockRef.current = await requestWakeLock();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
243
|
+
return () => {
|
|
244
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
245
|
+
};
|
|
246
|
+
}, [screenLock]);
|
|
247
|
+
// クリーンアップ
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
return () => {
|
|
250
|
+
shouldContinueRef.current = false;
|
|
251
|
+
if (recognitionRef.current) {
|
|
252
|
+
recognitionRef.current.stop();
|
|
253
|
+
recognitionRef.current = null;
|
|
254
|
+
}
|
|
255
|
+
if (keepAliveRef.current) {
|
|
256
|
+
stopKeepAlive(keepAliveRef.current);
|
|
257
|
+
keepAliveRef.current = null;
|
|
258
|
+
}
|
|
259
|
+
if (wakeLockRef.current) {
|
|
260
|
+
releaseWakeLock(wakeLockRef.current);
|
|
261
|
+
wakeLockRef.current = null;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}, []);
|
|
265
|
+
return {
|
|
266
|
+
isListening,
|
|
267
|
+
isSupported,
|
|
268
|
+
start,
|
|
269
|
+
stop,
|
|
270
|
+
error,
|
|
271
|
+
transcript,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type UserChoice = {
|
|
2
|
+
outcome: "accepted" | "dismissed";
|
|
3
|
+
platform: string;
|
|
4
|
+
};
|
|
5
|
+
interface BeforeInstallPromptEvent extends Event {
|
|
6
|
+
readonly platforms: string[];
|
|
7
|
+
readonly userChoice: Promise<UserChoice>;
|
|
8
|
+
prompt(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
declare global {
|
|
11
|
+
interface WindowEventMap {
|
|
12
|
+
beforeinstallprompt: BeforeInstallPromptEvent;
|
|
13
|
+
}
|
|
14
|
+
interface Navigator {
|
|
15
|
+
standalone?: boolean;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export type PwaData = {
|
|
19
|
+
canInstall: boolean;
|
|
20
|
+
install: () => Promise<UserChoice | undefined>;
|
|
21
|
+
isInstalled: boolean;
|
|
22
|
+
isSupported: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function usePwa(): PwaData;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=usePwa.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usePwa.d.ts","sourceRoot":"","sources":["../../src/hooks/usePwa.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,UAAU,GAAG,WAAW,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,UAAU,wBAAyB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IACzC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,cAAc;QACtB,mBAAmB,EAAE,wBAAwB,CAAC;KAC/C;IAED,UAAU,SAAS;QACjB,UAAU,CAAC,EAAE,OAAO,CAAC;KACtB;CACF;AAYD,MAAM,MAAM,OAAO,GAAG;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC,CAAC;IAC/C,WAAW,EAAE,OAAO,CAAC;IACrB,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF,wBAAgB,MAAM,IAAI,OAAO,CAsFhC"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
// Capture event at module load time (before React hydration)
|
|
4
|
+
let capturedEvent = null;
|
|
5
|
+
if (typeof window !== "undefined") {
|
|
6
|
+
window.addEventListener("beforeinstallprompt", (event) => {
|
|
7
|
+
event.preventDefault();
|
|
8
|
+
capturedEvent = event;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export function usePwa() {
|
|
12
|
+
const promptEvent = useRef(capturedEvent);
|
|
13
|
+
const [canInstall, setCanInstall] = useState(false);
|
|
14
|
+
const [isInstalled, setIsInstalled] = useState(false);
|
|
15
|
+
const [isSupported, setIsSupported] = useState(false);
|
|
16
|
+
const install = useCallback(async () => {
|
|
17
|
+
if (!promptEvent.current) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
await promptEvent.current.prompt();
|
|
21
|
+
const choice = await promptEvent.current.userChoice;
|
|
22
|
+
if (choice.outcome === "accepted") {
|
|
23
|
+
setCanInstall(false);
|
|
24
|
+
promptEvent.current = null;
|
|
25
|
+
capturedEvent = null;
|
|
26
|
+
}
|
|
27
|
+
return choice;
|
|
28
|
+
}, []);
|
|
29
|
+
// Check for captured event and listen for future events
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Use captured event if available
|
|
32
|
+
if (capturedEvent) {
|
|
33
|
+
promptEvent.current = capturedEvent;
|
|
34
|
+
setCanInstall(true);
|
|
35
|
+
}
|
|
36
|
+
const handleBeforeInstallPrompt = (event) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
promptEvent.current = event;
|
|
39
|
+
capturedEvent = event;
|
|
40
|
+
setCanInstall(true);
|
|
41
|
+
};
|
|
42
|
+
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
|
45
|
+
};
|
|
46
|
+
}, []);
|
|
47
|
+
// Detect if running as installed PWA
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
// Android Trusted Web App
|
|
50
|
+
if (document.referrer.includes("android-app://")) {
|
|
51
|
+
setIsInstalled(true);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Chrome PWA (supporting fullscreen, standalone, minimal-ui)
|
|
55
|
+
const displayModes = ["fullscreen", "standalone", "minimal-ui"];
|
|
56
|
+
const isDisplayModePwa = displayModes.some((mode) => window.matchMedia(`(display-mode: ${mode})`).matches);
|
|
57
|
+
if (isDisplayModePwa) {
|
|
58
|
+
setIsInstalled(true);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// iOS PWA Standalone
|
|
62
|
+
if (navigator.standalone) {
|
|
63
|
+
setIsInstalled(true);
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
// Detect PWA support
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if ("BeforeInstallPromptEvent" in window) {
|
|
69
|
+
setIsSupported(true);
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
return {
|
|
73
|
+
canInstall,
|
|
74
|
+
install,
|
|
75
|
+
isInstalled,
|
|
76
|
+
isSupported,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACxC,YAAY,EACV,aAAa,EACb,YAAY,EACZ,QAAQ,EACR,aAAa,GACd,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useEar } from "./hooks/useEar";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface WakeWord {
|
|
2
|
+
/** ウェイクワード */
|
|
3
|
+
word: string;
|
|
4
|
+
/** このワード用の言語設定 */
|
|
5
|
+
language: string;
|
|
6
|
+
}
|
|
7
|
+
export type WakeWordInput = string | WakeWord;
|
|
8
|
+
export interface UseEarOptions {
|
|
9
|
+
/** 検出するウェイクアップワードの配列 */
|
|
10
|
+
wakeWords: WakeWordInput[];
|
|
11
|
+
/** ウェイクワード検出時に呼ばれるコールバック */
|
|
12
|
+
onWakeWord: (word: string, transcript: string) => void;
|
|
13
|
+
/** リッスンを停止するワードの配列 */
|
|
14
|
+
stopWords?: WakeWordInput[];
|
|
15
|
+
/** ストップワード検出時に呼ばれるコールバック */
|
|
16
|
+
onStopWord?: (word: string, transcript: string) => void;
|
|
17
|
+
/** 継続的にリッスンするか (default: true) */
|
|
18
|
+
continuous?: boolean;
|
|
19
|
+
/** デフォルトの言語設定 (default: 'ja-JP') */
|
|
20
|
+
language?: string;
|
|
21
|
+
/** 大文字小文字を区別しない (default: false) */
|
|
22
|
+
caseSensitive?: boolean;
|
|
23
|
+
/** モバイルでバックグラウンド時もオーディオセッションを維持する (default: true) */
|
|
24
|
+
keepAlive?: boolean;
|
|
25
|
+
/** 画面の自動ロックを防ぐ (default: false) */
|
|
26
|
+
screenLock?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface UseEarReturn {
|
|
29
|
+
/** 現在リッスン中かどうか */
|
|
30
|
+
isListening: boolean;
|
|
31
|
+
/** ブラウザがWeb Speech APIをサポートしているか */
|
|
32
|
+
isSupported: boolean;
|
|
33
|
+
/** リッスンを開始 */
|
|
34
|
+
start: () => void;
|
|
35
|
+
/** リッスンを停止 */
|
|
36
|
+
stop: () => void;
|
|
37
|
+
/** エラー情報 */
|
|
38
|
+
error: Error | null;
|
|
39
|
+
/** 最後に認識されたテキスト */
|
|
40
|
+
transcript: string;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,QAAQ;IACvB,cAAc;IACd,IAAI,EAAE,MAAM,CAAC;IACb,kBAAkB;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE9C,MAAM,WAAW,aAAa;IAC5B,wBAAwB;IACxB,SAAS,EAAE,aAAa,EAAE,CAAC;IAC3B,4BAA4B;IAC5B,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACvD,sBAAsB;IACtB,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAC5B,4BAA4B;IAC5B,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD,kCAAkC;IAClC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,oCAAoC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oCAAoC;IACpC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qDAAqD;IACrD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,mCAAmC;IACnC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,kBAAkB;IAClB,WAAW,EAAE,OAAO,CAAC;IACrB,oCAAoC;IACpC,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc;IACd,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,cAAc;IACd,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,YAAY;IACZ,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IACpB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "use-ear",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks for wake word detection using Web Speech API",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react",
|
|
20
|
+
"hooks",
|
|
21
|
+
"speech-recognition",
|
|
22
|
+
"wake-word",
|
|
23
|
+
"voice",
|
|
24
|
+
"web-speech-api"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/piro0919/use-ear.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/piro0919/use-ear#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/piro0919/use-ear/issues"
|
|
34
|
+
},
|
|
35
|
+
"author": "piro0919",
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "next dev",
|
|
38
|
+
"build": "next build",
|
|
39
|
+
"build:lib": "tsc -p tsconfig.build.json",
|
|
40
|
+
"prepublishOnly": "npm run build:lib",
|
|
41
|
+
"start": "next start",
|
|
42
|
+
"lint": "biome check",
|
|
43
|
+
"format": "biome format --write"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": ">=18.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@biomejs/biome": "2.2.0",
|
|
50
|
+
"@serwist/turbopack": "^9.4.2",
|
|
51
|
+
"@tailwindcss/postcss": "^4",
|
|
52
|
+
"@types/node": "^20",
|
|
53
|
+
"@types/react": "^19",
|
|
54
|
+
"@types/react-dom": "^19",
|
|
55
|
+
"babel-plugin-react-compiler": "1.0.0",
|
|
56
|
+
"esbuild-wasm": "^0.27.2",
|
|
57
|
+
"next": "16.1.1",
|
|
58
|
+
"react": "19.2.3",
|
|
59
|
+
"react-dom": "19.2.3",
|
|
60
|
+
"react-ios-pwa-prompt": "^2.0.6",
|
|
61
|
+
"serwist": "^9.4.2",
|
|
62
|
+
"tailwindcss": "^4",
|
|
63
|
+
"typescript": "^5"
|
|
64
|
+
}
|
|
65
|
+
}
|