Skip to content

Commit

Permalink
Add remix example
Browse files Browse the repository at this point in the history
  • Loading branch information
mnemitz committed Dec 10, 2024
1 parent 89b287f commit cd7e764
Show file tree
Hide file tree
Showing 26 changed files with 6,836 additions and 132 deletions.
2 changes: 1 addition & 1 deletion examples/nextjs/src/app/flow/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default async function Home() {
const personas = await fetchPersonas();

return (
<PcmAudioRecorderProvider>
<PcmAudioRecorderProvider workletScriptURL="/js/pcm-audio-worklet.min.js">
<FlowProvider appId="nextjs-example">
<Component personas={personas} />
</FlowProvider>
Expand Down
42 changes: 0 additions & 42 deletions examples/nextjs/src/lib/context/audio-context.tsx

This file was deleted.

1 change: 0 additions & 1 deletion examples/nextjs/src/worklet.ts

This file was deleted.

84 changes: 84 additions & 0 deletions examples/remix/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/

/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
ignorePatterns: ['!**/.server', '!**/.client'],

// Base config
extends: ['eslint:recommended'],

overrides: [
// React
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: ['react', 'jsx-a11y'],
extends: [
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
settings: {
react: {
version: 'detect',
},
formComponents: ['Form'],
linkComponents: [
{ name: 'Link', linkAttribute: 'to' },
{ name: 'NavLink', linkAttribute: 'to' },
],
'import/resolver': {
typescript: {},
},
},
},

// Typescript
{
files: ['**/*.{ts,tsx}'],
plugins: ['@typescript-eslint', 'import'],
parser: '@typescript-eslint/parser',
settings: {
'import/internal-regex': '^~/',
'import/resolver': {
node: {
extensions: ['.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
],
},

// Node
{
files: ['.eslintrc.cjs'],
env: {
node: true,
},
},
],
};
5 changes: 5 additions & 0 deletions examples/remix/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules

/.cache
/build
.env
40 changes: 40 additions & 0 deletions examples/remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Welcome to Remix!

- 📖 [Remix docs](https://remix.run/docs)

## Development

Run the dev server:

```shellscript
npm run dev
```

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to.

### DIY

If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.

Make sure to deploy the output of `npm run build`

- `build/server`
- `build/client`

## Styling

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.
55 changes: 55 additions & 0 deletions examples/remix/app/components/MicrophoneSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useAudioDevices } from '@speechmatics/browser-audio-input-react';
import type { ChangeEvent } from 'react';

export function MicrophoneSelect({
setDeviceId,
}: { setDeviceId: (deviceId: string) => void }) {
const devices = useAudioDevices();

switch (devices.permissionState) {
case 'prompt':
return (
<label>
Enable mic permissions
<select
onClick={devices.promptPermissions}
onKeyDown={devices.promptPermissions}
/>
</label>
);
case 'prompting':
return (
<label>
Enable mic permissions
<select aria-busy="true" />
</label>
);
case 'granted': {
const onChange = (e: ChangeEvent<HTMLSelectElement>) => {
setDeviceId(e.target.value);
};
return (
<label>
Select audio device
<select onChange={onChange}>
{devices.deviceList.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
</label>
);
}
case 'denied':
return (
<label>
Microphone permission disabled
<select disabled />
</label>
);
default:
devices satisfies never;
return null;
}
}
16 changes: 16 additions & 0 deletions examples/remix/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
dt::after {
content: ": ";
}

dl {
display: grid;
grid-template-rows: max-content auto;
}

dt {
grid-column-start: 1;
}

dd {
grid-column-start: 2;
}
64 changes: 64 additions & 0 deletions examples/remix/app/hooks/use-play-pcm-audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useRef, useEffect, useCallback } from 'react';

export function usePlayPcm16Audio(audioContext: AudioContext | undefined) {
const playbackStartTime = useRef(0);

useEffect(() => {
// Reset if audio context is cleared for some reason
if (!audioContext) {
playbackStartTime.current = 0;
}
// Otherwise reset on context close
const onStateChange = () => {
if (audioContext?.state === 'closed') {
playbackStartTime.current = 0;
}
};
audioContext?.addEventListener('statechange', onStateChange);
return () =>
audioContext?.removeEventListener('statechange', onStateChange);
}, [audioContext]);

return useCallback(
(pcmData: Int16Array) => {
if (!audioContext) {
console.warn('Audio context not initialized for playback!');
return;
}
if (audioContext.state === 'closed') {
console.warn('Audio context closed');
return;
}

const float32Array = pcm16ToFloat32(pcmData);
const audioBuffer = audioContext.createBuffer(
1,
float32Array.length,
audioContext.sampleRate,
);
audioBuffer.copyToChannel(float32Array, 0);

const source = audioContext.createBufferSource();
source.buffer = audioBuffer;

const currentTime = audioContext.currentTime;
if (playbackStartTime.current < currentTime) {
playbackStartTime.current = currentTime;
}

source.connect(audioContext.destination);
source.start(playbackStartTime.current);

playbackStartTime.current += audioBuffer.duration;
},
[audioContext],
);
}

const pcm16ToFloat32 = (pcm16: Int16Array) => {
const float32 = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) {
float32[i] = pcm16[i] / 32768; // Convert PCM16 to Float32
}
return float32;
};
33 changes: 33 additions & 0 deletions examples/remix/app/hooks/use-speechmatics-jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useFetcher } from '@remix-run/react';
import { useCallback, useEffect, useRef } from 'react';

// This hook provides a basic way to fetch temporary JWTs on the fly.
// Due to some restrictions of Remix, it is a bit of a workaround.
// At time of writing, Remix still doesn't have a good way to await fetch/loaded data from within a callback
// This is due to its philosophy of minimizing data waterfalls, which is understandable.
// However, since there is a need for React 19 transitions compatibility, this feature is being worked on:
// https://github.com/orgs/remix-run/projects/5?pane=issue&itemId=62177552
// TODO: Once the above feature lands in Remix, update this hook to use that instead.
export function useSpeechmaticsJWT(type: 'flow' | 'rt') {
const fetcher = useFetcher<{ jwt: string }>();
const resolveRef = useRef<(value: string) => void>();

useEffect(() => {
if (resolveRef.current && fetcher.data) {
resolveRef.current?.(fetcher.data.jwt);
resolveRef.current = undefined;
}
}, [fetcher.data]);

return useCallback(async () => {
const fd = new FormData();
fd.set('type', type);

// @ts-ignore: This is widely available on browsers
const { promise, resolve } = Promise.withResolvers();
resolveRef.current = resolve;

fetcher.submit(fd, { action: '/jwt', method: 'post' });
return (await promise) as string;
}, [fetcher, type]);
}
Loading

0 comments on commit cd7e764

Please sign in to comment.