Skip to content

Commit f6a7a01

Browse files
committed
Add remix example
1 parent cd19e7c commit f6a7a01

19 files changed

+6735
-70
lines changed

examples/remix/.eslintrc.cjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* This is intended to be a basic starting point for linting in your app.
3+
* It relies on recommended configs out of the box for simplicity, but you can
4+
* and should modify this configuration to best suit your team's needs.
5+
*/
6+
7+
/** @type {import('eslint').Linter.Config} */
8+
module.exports = {
9+
root: true,
10+
parserOptions: {
11+
ecmaVersion: 'latest',
12+
sourceType: 'module',
13+
ecmaFeatures: {
14+
jsx: true,
15+
},
16+
},
17+
env: {
18+
browser: true,
19+
commonjs: true,
20+
es6: true,
21+
},
22+
ignorePatterns: ['!**/.server', '!**/.client'],
23+
24+
// Base config
25+
extends: ['eslint:recommended'],
26+
27+
overrides: [
28+
// React
29+
{
30+
files: ['**/*.{js,jsx,ts,tsx}'],
31+
plugins: ['react', 'jsx-a11y'],
32+
extends: [
33+
'plugin:react/recommended',
34+
'plugin:react/jsx-runtime',
35+
'plugin:react-hooks/recommended',
36+
'plugin:jsx-a11y/recommended',
37+
],
38+
settings: {
39+
react: {
40+
version: 'detect',
41+
},
42+
formComponents: ['Form'],
43+
linkComponents: [
44+
{ name: 'Link', linkAttribute: 'to' },
45+
{ name: 'NavLink', linkAttribute: 'to' },
46+
],
47+
'import/resolver': {
48+
typescript: {},
49+
},
50+
},
51+
},
52+
53+
// Typescript
54+
{
55+
files: ['**/*.{ts,tsx}'],
56+
plugins: ['@typescript-eslint', 'import'],
57+
parser: '@typescript-eslint/parser',
58+
settings: {
59+
'import/internal-regex': '^~/',
60+
'import/resolver': {
61+
node: {
62+
extensions: ['.ts', '.tsx'],
63+
},
64+
typescript: {
65+
alwaysTryTypes: true,
66+
},
67+
},
68+
},
69+
extends: [
70+
'plugin:@typescript-eslint/recommended',
71+
'plugin:import/recommended',
72+
'plugin:import/typescript',
73+
],
74+
},
75+
76+
// Node
77+
{
78+
files: ['.eslintrc.cjs'],
79+
env: {
80+
node: true,
81+
},
82+
},
83+
],
84+
};

examples/remix/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
.env

examples/remix/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Welcome to Remix!
2+
3+
- 📖 [Remix docs](https://remix.run/docs)
4+
5+
## Development
6+
7+
Run the dev server:
8+
9+
```shellscript
10+
npm run dev
11+
```
12+
13+
## Deployment
14+
15+
First, build your app for production:
16+
17+
```sh
18+
npm run build
19+
```
20+
21+
Then run the app in production mode:
22+
23+
```sh
24+
npm start
25+
```
26+
27+
Now you'll need to pick a host to deploy it to.
28+
29+
### DIY
30+
31+
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
32+
33+
Make sure to deploy the output of `npm run build`
34+
35+
- `build/server`
36+
- `build/client`
37+
38+
## Styling
39+
40+
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useAudioDevices } from '@speechmatics/browser-audio-input-react';
2+
import type { ChangeEvent } from 'react';
3+
4+
export function MicrophoneSelect({
5+
setDeviceId,
6+
}: { setDeviceId: (deviceId: string) => void }) {
7+
const devices = useAudioDevices();
8+
9+
switch (devices.permissionState) {
10+
case 'prompt':
11+
return (
12+
<label>
13+
Enable mic permissions
14+
<select
15+
onClick={devices.promptPermissions}
16+
onKeyDown={devices.promptPermissions}
17+
/>
18+
</label>
19+
);
20+
case 'prompting':
21+
return (
22+
<label>
23+
Enable mic permissions
24+
<select aria-busy="true" />
25+
</label>
26+
);
27+
case 'granted': {
28+
const onChange = (e: ChangeEvent<HTMLSelectElement>) => {
29+
setDeviceId(e.target.value);
30+
};
31+
return (
32+
<label>
33+
Select audio device
34+
<select onChange={onChange}>
35+
{devices.deviceList.map((d) => (
36+
<option key={d.deviceId} value={d.deviceId}>
37+
{d.label}
38+
</option>
39+
))}
40+
</select>
41+
</label>
42+
);
43+
}
44+
case 'denied':
45+
return (
46+
<label>
47+
Microphone permission disabled
48+
<select disabled />
49+
</label>
50+
);
51+
default:
52+
devices satisfies never;
53+
return null;
54+
}
55+
}

examples/remix/app/globals.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
dt::after {
2+
content: ": ";
3+
}
4+
5+
dl {
6+
display: grid;
7+
grid-template-rows: max-content auto;
8+
}
9+
10+
dt {
11+
grid-column-start: 1;
12+
}
13+
14+
dd {
15+
grid-column-start: 2;
16+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useRef, useEffect, useCallback } from 'react';
2+
3+
export function usePlayPcm16Audio(audioContext: AudioContext | undefined) {
4+
const playbackStartTime = useRef(0);
5+
6+
useEffect(() => {
7+
// Reset if audio context is cleared for some reason
8+
if (!audioContext) {
9+
playbackStartTime.current = 0;
10+
}
11+
// Otherwise reset on context close
12+
const onStateChange = () => {
13+
if (audioContext?.state === 'closed') {
14+
playbackStartTime.current = 0;
15+
}
16+
};
17+
audioContext?.addEventListener('statechange', onStateChange);
18+
return () =>
19+
audioContext?.removeEventListener('statechange', onStateChange);
20+
}, [audioContext]);
21+
22+
return useCallback(
23+
(pcmData: Int16Array) => {
24+
if (!audioContext) {
25+
console.warn('Audio context not initialized for playback!');
26+
return;
27+
}
28+
if (audioContext.state === 'closed') {
29+
console.warn('Audio context closed');
30+
return;
31+
}
32+
33+
const float32Array = pcm16ToFloat32(pcmData);
34+
const audioBuffer = audioContext.createBuffer(
35+
1,
36+
float32Array.length,
37+
audioContext.sampleRate,
38+
);
39+
audioBuffer.copyToChannel(float32Array, 0);
40+
41+
const source = audioContext.createBufferSource();
42+
source.buffer = audioBuffer;
43+
44+
const currentTime = audioContext.currentTime;
45+
if (playbackStartTime.current < currentTime) {
46+
playbackStartTime.current = currentTime;
47+
}
48+
49+
source.connect(audioContext.destination);
50+
source.start(playbackStartTime.current);
51+
52+
playbackStartTime.current += audioBuffer.duration;
53+
},
54+
[audioContext],
55+
);
56+
}
57+
58+
const pcm16ToFloat32 = (pcm16: Int16Array) => {
59+
const float32 = new Float32Array(pcm16.length);
60+
for (let i = 0; i < pcm16.length; i++) {
61+
float32[i] = pcm16[i] / 32768; // Convert PCM16 to Float32
62+
}
63+
return float32;
64+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useFetcher } from '@remix-run/react';
2+
import { useCallback, useEffect, useRef } from 'react';
3+
4+
// This hook provides a basic way to fetch temporary JWTs on the fly.
5+
// Due to some restrictions of Remix, it is a bit of a workaround.
6+
// At time of writing, Remix still doesn't have a good way to await fetch/loaded data from within a callback
7+
// This is due to its philosophy of minimizing data waterfalls, which is understandable.
8+
// However, since there is a need for React 19 transitions compatibility, this feature is being worked on:
9+
// https://github.com/orgs/remix-run/projects/5?pane=issue&itemId=62177552
10+
// TODO: Once the above feature lands in Remix, update this hook to use that instead.
11+
export function useSpeechmaticsJWT(type: 'flow' | 'rt') {
12+
const fetcher = useFetcher<{ jwt: string }>();
13+
const resolveRef = useRef<(value: string) => void>();
14+
15+
useEffect(() => {
16+
if (resolveRef.current && fetcher.data) {
17+
resolveRef.current?.(fetcher.data.jwt);
18+
resolveRef.current = undefined;
19+
}
20+
}, [fetcher.data]);
21+
22+
return useCallback(async () => {
23+
const fd = new FormData();
24+
fd.set('type', type);
25+
26+
// @ts-ignore: This is widely available on browsers
27+
const { promise, resolve } = Promise.withResolvers();
28+
resolveRef.current = resolve;
29+
30+
fetcher.submit(fd, { action: '/jwt', method: 'post' });
31+
return (await promise) as string;
32+
}, [fetcher, type]);
33+
}

examples/remix/app/root.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
Links,
3+
Meta,
4+
Outlet,
5+
Scripts,
6+
ScrollRestoration,
7+
} from '@remix-run/react';
8+
9+
import '@picocss/pico';
10+
import './globals.css';
11+
12+
export function Layout({ children }: { children: React.ReactNode }) {
13+
return (
14+
<html lang="en">
15+
<head>
16+
<meta charSet="utf-8" />
17+
<meta name="viewport" content="width=device-width, initial-scale=1" />
18+
<Meta />
19+
<Links />
20+
</head>
21+
<body>
22+
<ScrollRestoration />
23+
<Scripts />
24+
<header className="container">
25+
<nav>
26+
<ul>
27+
<li>
28+
<h6>Speechmatics ❤️ Remix</h6>
29+
</li>
30+
</ul>
31+
<ul>
32+
<li>
33+
<a href="/flow">Flow</a>
34+
</li>
35+
<li>
36+
<a href="/batch">Batch</a>
37+
</li>
38+
<li>
39+
<a href="/real-time">Real-time</a>
40+
</li>
41+
</ul>
42+
</nav>
43+
</header>
44+
45+
<main className="container">{children}</main>
46+
</body>
47+
</html>
48+
);
49+
}
50+
51+
export default function App() {
52+
return <Outlet />;
53+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { redirect, type MetaFunction } from '@remix-run/node';
2+
3+
export const meta: MetaFunction = () => {
4+
return [
5+
{ title: 'Speechmatics Remix Example' },
6+
{
7+
name: 'description',
8+
content: 'Remix example showcasing the Speechmatics JS SDK',
9+
},
10+
];
11+
};
12+
13+
export async function loader() {
14+
return redirect('/flow');
15+
}

0 commit comments

Comments
 (0)