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 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
+ }
@@ -0,0 +1,3 @@
1
+ export { useEar } from "./hooks/useEar";
2
+ export type { UseEarOptions, UseEarReturn, WakeWord, WakeWordInput, } from "./types";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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
+ }