├── .gitignore
├── LICENSE
├── README.md
├── package.json
├── public
└── index.html
├── src
├── __tests__
│ ├── .eslintrc
│ ├── components
│ │ └── TranscriptDisplay.js
│ └── helpers
│ │ ├── applyCorrection.test.js
│ │ ├── isMatchComplete.test.js
│ │ └── matchCorrection.test.js
├── components
│ ├── App.js
│ ├── Instructions.js
│ ├── TranscriptDisplay.css
│ └── TranscriptDisplay.js
├── helpers
│ ├── applyCorrection.js
│ ├── isMatchComplete.js
│ └── matchCorrection.js
├── index.css
├── index.js
├── media
│ ├── audio.mp3
│ └── transcript.json
└── setupTests.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Alex Norton
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Overtyper
2 |
3 | Experiment in automatic insertion of timed transcript corrections. An implementation of an idea shared by [Mark Boas](https://twitter.com/maboa) at [TextAV](https://sites.google.com/view/textav) in July 2017.
4 |
5 | Built using React and Create React App.
6 |
7 | Comments? Get in touch with me on [Twitter](https://twitter.com/alxnorton).
8 |
9 | ## Demo
10 |
11 | 👉 [https://alexnorton.github.io/overtyper/](https://alexnorton.github.io/overtyper/) 👈
12 |
13 | ## Development
14 |
15 | Clone this repository, then install the dependencies:
16 |
17 | ```bash
18 | $ npm install
19 | ```
20 |
21 | Start the development server:
22 |
23 | ```bash
24 | $ npm start
25 | ```
26 |
27 | And navigate to [http://localhost:3000/](http://localhost:3000/).
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "overtyper",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "bulma": "^0.4.3",
7 | "fast-levenshtein": "^2.0.6",
8 | "prop-types": "^15.5.10",
9 | "react": "16.0.0",
10 | "react-dom": "16.0.0"
11 | },
12 | "scripts": {
13 | "start": "react-scripts start",
14 | "build": "react-scripts build",
15 | "test": "react-scripts test --env=jsdom",
16 | "coverage": "npm run test -- --coverage",
17 | "eject": "react-scripts eject",
18 | "predeploy": "npm run build",
19 | "deploy": "gh-pages -d build"
20 | },
21 | "devDependencies": {
22 | "enzyme": "^3.1.0",
23 | "enzyme-adapter-react-16": "^1.0.2",
24 | "eslint": "^4.3.0",
25 | "eslint-config-airbnb": "^15.1.0",
26 | "eslint-plugin-import": "^2.7.0",
27 | "eslint-plugin-jsx-a11y": "^5.1.1",
28 | "eslint-plugin-react": "^7.1.0",
29 | "gh-pages": "^1.0.0",
30 | "jest-enzyme": "^4.0.1",
31 | "react-scripts": "^1.0.15",
32 | "react-test-renderer": "^16.0.0"
33 | },
34 | "homepage": "https://alexnorton.github.io/overtyper",
35 | "eslintConfig": {
36 | "extends": "airbnb",
37 | "rules": {
38 | "react/jsx-filename-extension": [
39 | 1,
40 | {
41 | "extensions": [
42 | ".js",
43 | ".jsx"
44 | ]
45 | }
46 | ],
47 | "react/no-array-index-key": "off",
48 | "import/no-extraneous-dependencies": [
49 | "error",
50 | {
51 | "devDependencies": [
52 | "**/*.test.js",
53 | "**/*.spec.js",
54 | "src/setupTests.js"
55 | ]
56 | }
57 | ]
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | Overtyper
23 |
24 |
25 |
26 | You need to enable JavaScript to run this app.
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/__tests__/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
--------------------------------------------------------------------------------
/src/__tests__/components/TranscriptDisplay.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow } from 'enzyme';
3 |
4 | import { CorrectionWindow } from '../../components/TranscriptDisplay';
5 |
6 | describe('CorrectionWindow', () => {
7 | // Hipster Ipsum 🙃
8 | const correctablePlayedWords = [
9 | { text: 'Vegan' },
10 | { text: 'tumeric' },
11 | { text: 'four' },
12 | { text: 'dollar' },
13 | { text: 'toast,' },
14 | { text: 'lo-fi' },
15 | { text: 'quinoa' },
16 | { text: 'palo' },
17 | { text: 'santo' },
18 | { text: 'fam' },
19 | { text: 'wolf' },
20 | { text: 'try-hard' },
21 | { text: 'whatever.' },
22 | ];
23 |
24 | it('Renders correctly when there is no match', () => {
25 | const match = null;
26 |
27 | expect(shallow( )).toHaveHTML(
31 | 'Vegan tumeric four dollar toast, lo-fi quinoa palo santo fam wolf try-hard whatever. '
32 | );
33 | });
34 |
35 | it('Renders correctly when there is an incomplete match with no replacement', () => {
36 | const match = {
37 | start: { index: 2, length: 2 },
38 | replacement: null,
39 | end: null,
40 | };
41 |
42 | expect(shallow( )).toHaveHTML(
46 | 'Vegan tumeric four dollar toast, lo-fi quinoa palo santo fam wolf try-hard whatever. '
47 | );
48 | });
49 |
50 | it('Renders correctly when there is an incomplete match with a replacement', () => {
51 | const match = {
52 | start: { index: 2, length: 2 },
53 | replacement: 'edison bulb tofu',
54 | end: null,
55 | };
56 |
57 | expect(shallow( )).toHaveHTML(
61 | 'Vegan tumeric four dollar edison bulb tofu toast, lo-fi quinoa palo santo fam wolf try-hard whatever. '
62 | );
63 | });
64 |
65 | it('Renders correctly when there is a complete match', () => {
66 | const match = {
67 | start: { index: 2, length: 2 },
68 | replacement: 'edison bulb tofu',
69 | end: { index: 6, length: 3 },
70 | };
71 |
72 | expect(shallow( )).toHaveHTML(
76 | 'Vegan tumeric four dollar toast, lo-fi edison bulb tofu quinoa palo santo fam wolf try-hard whatever. '
77 | );
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/__tests__/helpers/applyCorrection.test.js:
--------------------------------------------------------------------------------
1 | import applyCorrection from '../../helpers/applyCorrection';
2 |
3 | describe('applyCorrection', () => {
4 | const transcript = [
5 | { text: 'He', start: 0.05, end: 0.64 },
6 | { text: 'was', start: 0.65, end: 0.96 },
7 | { text: 'the', start: 0.96, end: 1.06 },
8 | { text: 'head', start: 1.06, end: 1.39 },
9 | { text: 'of', start: 1.39, end: 1.52 },
10 | { text: 'diagnostic', start: 1.53, end: 2.18 },
11 | { text: 'medicine', start: 2.18, end: 2.57 },
12 | { text: 'is', start: 3.7, end: 4 },
13 | { text: 'remarkable', start: 4.01, end: 4.82 },
14 | { text: 'notion', start: 4.85, end: 5.33 },
15 | { text: 'remarkable', start: 5.37, end: 5.91 },
16 | { text: 'character', start: 5.91, end: 6.47 },
17 | { text: 'more', start: 7.11, end: 7.36 },
18 | { text: 'remarkable', start: 7.36, end: 7.80 },
19 | { text: 'then', start: 7.8, end: 8.22 },
20 | { text: 'than', start: 8.22, end: 8.41 },
21 | { text: 'now.', start: 8.42, end: 8.82 },
22 | { text: 'I', start: 8.82, end: 8.95 },
23 | { text: 'think', start: 8.95, end: 9.33 },
24 | { text: 'in', start: 9.36, end: 9.75 },
25 | { text: 'that', start: 9.78, end: 10.28 },
26 | { text: 'up', start: 10.79, end: 11.01 },
27 | { text: 'until', start: 11.01, end: 11.39 },
28 | { text: 'that', start: 11.39, end: 11.60 },
29 | { text: 'point', start: 11.6, end: 11.96 },
30 | ];
31 |
32 | it('passes through the transcript when there is no correction', () => {
33 | const match = null;
34 | const windowStart = 4;
35 |
36 | const correctedTranscript = applyCorrection(transcript, windowStart, match);
37 |
38 | expect(correctedTranscript).toEqual(transcript);
39 | });
40 |
41 | it('applies correction when the replacement has the same number of words', () => {
42 | const match = {
43 | start: { index: 2, length: 1 },
44 | replacement: 'this reshmarkable',
45 | end: { index: 5, length: 2 },
46 | };
47 | const windowStart = 4;
48 |
49 | const correctedTranscript = applyCorrection(transcript, windowStart, match);
50 |
51 | expect(correctedTranscript).toEqual([
52 | { text: 'He', start: 0.05, end: 0.64 },
53 | { text: 'was', start: 0.65, end: 0.96 },
54 | { text: 'the', start: 0.96, end: 1.06 },
55 | { text: 'head', start: 1.06, end: 1.39 },
56 | { text: 'of', start: 1.39, end: 1.52 },
57 | { text: 'diagnostic', start: 1.53, end: 2.18 },
58 | { text: 'medicine', start: 2.18, end: 2.57 },
59 | { text: 'this', start: 3.7, end: 4 },
60 | { text: 'reshmarkable', start: 4.01, end: 4.82 },
61 | { text: 'notion', start: 4.85, end: 5.33 },
62 | { text: 'remarkable', start: 5.37, end: 5.91 },
63 | { text: 'character', start: 5.91, end: 6.47 },
64 | { text: 'more', start: 7.11, end: 7.36 },
65 | { text: 'remarkable', start: 7.36, end: 7.80 },
66 | { text: 'then', start: 7.8, end: 8.22 },
67 | { text: 'than', start: 8.22, end: 8.41 },
68 | { text: 'now.', start: 8.42, end: 8.82 },
69 | { text: 'I', start: 8.82, end: 8.95 },
70 | { text: 'think', start: 8.95, end: 9.33 },
71 | { text: 'in', start: 9.36, end: 9.75 },
72 | { text: 'that', start: 9.78, end: 10.28 },
73 | { text: 'up', start: 10.79, end: 11.01 },
74 | { text: 'until', start: 11.01, end: 11.39 },
75 | { text: 'that', start: 11.39, end: 11.60 },
76 | { text: 'point', start: 11.6, end: 11.96 },
77 | ]);
78 | });
79 |
80 | it('applies correction when the replacement has a different number of words', () => {
81 | const match = {
82 | start: { index: 2, length: 1 },
83 | replacement: 'this is a',
84 | end: { index: 4, length: 2 },
85 | };
86 | const windowStart = 4;
87 |
88 | const correctedTranscript = applyCorrection(transcript, windowStart, match);
89 |
90 | expect(correctedTranscript).toEqual([
91 | { text: 'He', start: 0.05, end: 0.64 },
92 | { text: 'was', start: 0.65, end: 0.96 },
93 | { text: 'the', start: 0.96, end: 1.06 },
94 | { text: 'head', start: 1.06, end: 1.39 },
95 | { text: 'of', start: 1.39, end: 1.52 },
96 | { text: 'diagnostic', start: 1.53, end: 2.18 },
97 | { text: 'medicine', start: 2.18, end: 2.57 },
98 | { text: 'this is a', start: 3.7, end: 4 },
99 | { text: 'remarkable', start: 4.01, end: 4.82 },
100 | { text: 'notion', start: 4.85, end: 5.33 },
101 | { text: 'remarkable', start: 5.37, end: 5.91 },
102 | { text: 'character', start: 5.91, end: 6.47 },
103 | { text: 'more', start: 7.11, end: 7.36 },
104 | { text: 'remarkable', start: 7.36, end: 7.80 },
105 | { text: 'then', start: 7.8, end: 8.22 },
106 | { text: 'than', start: 8.22, end: 8.41 },
107 | { text: 'now.', start: 8.42, end: 8.82 },
108 | { text: 'I', start: 8.82, end: 8.95 },
109 | { text: 'think', start: 8.95, end: 9.33 },
110 | { text: 'in', start: 9.36, end: 9.75 },
111 | { text: 'that', start: 9.78, end: 10.28 },
112 | { text: 'up', start: 10.79, end: 11.01 },
113 | { text: 'until', start: 11.01, end: 11.39 },
114 | { text: 'that', start: 11.39, end: 11.60 },
115 | { text: 'point', start: 11.6, end: 11.96 },
116 | ]);
117 | });
118 | });
119 |
--------------------------------------------------------------------------------
/src/__tests__/helpers/isMatchComplete.test.js:
--------------------------------------------------------------------------------
1 | import isMatchComplete from '../../helpers/isMatchComplete';
2 |
3 | describe('isMatchComplete', () => {
4 | it('returns false for null matches', () => {
5 | const match = null;
6 |
7 | expect(isMatchComplete(match)).toBe(false);
8 | });
9 |
10 | it('returns false for an incomplete match with no replacement', () => {
11 | const match = {
12 | start: { index: 3, length: 2 },
13 | replacement: null,
14 | end: null,
15 | };
16 |
17 | expect(isMatchComplete(match)).toBe(false);
18 | });
19 |
20 | it('returns false for an incomplete match with replacement', () => {
21 | const match = {
22 | start: { index: 3, length: 2 },
23 | replacement: 'hi there',
24 | end: null,
25 | };
26 |
27 | expect(isMatchComplete(match)).toBe(false);
28 | });
29 |
30 | it('returns true for an complete match', () => {
31 | const match = {
32 | start: { index: 3, length: 2 },
33 | replacement: 'hi there',
34 | end: { index: 6, length: 1 },
35 | };
36 |
37 | expect(isMatchComplete(match)).toBe(true);
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/__tests__/helpers/matchCorrection.test.js:
--------------------------------------------------------------------------------
1 | import matchCorrection, {
2 | getTokens,
3 | getForwardsMatches,
4 | getBackwardsMatches,
5 | } from '../../helpers/matchCorrection';
6 |
7 | const transcript = [
8 | 'hair.', 'Lots', 'of', 'it', 'splendid', 'teeth', 'and', 'find', 'your', 'lines', 'and', 'had', 'to', 'find',
9 | ];
10 |
11 | describe('getTokens', () => {
12 | it('converts input to tokens correctly', () => {
13 | const input = 'Scottish Government remains committed strongly to the principle of giving Scotland, a choice at the end of this process.';
14 |
15 | const output = getTokens(input);
16 |
17 | expect(output).toEqual(['Scottish', 'Government', 'remains', 'committed', 'strongly', 'to', 'the', 'principle', 'of', 'giving', 'Scotland,', 'a', 'choice', 'at', 'the', 'end', 'of', 'this', 'process.']);
18 | });
19 | });
20 |
21 | describe('matchCorrection', () => {
22 | it('returns no matches', () => {
23 | const correction = 'blah hello strawberries';
24 |
25 | const match = matchCorrection(transcript, correction);
26 |
27 | expect(match).toEqual(null);
28 | });
29 |
30 | it('returns complete matches', () => {
31 | const correction = 'and fine jaw lines';
32 |
33 | const match = matchCorrection(transcript, correction);
34 |
35 | expect(match).toEqual({
36 | start: { index: 6, length: 1 },
37 | replacement: 'fine jaw',
38 | end: { index: 9, length: 1 },
39 | });
40 | });
41 |
42 | it('returns partial matches', () => {
43 | const correction = 'and fine jaw';
44 |
45 | const match = matchCorrection(transcript, correction);
46 |
47 | expect(match).toEqual({
48 | start: { index: 6, length: 1 },
49 | replacement: 'fine jaw',
50 | end: null,
51 | });
52 | });
53 | });
54 |
55 | describe('getForwardsMatches', () => {
56 | it('returns no matches', () => {
57 | const tokens = ['strawberries', 'hello'];
58 |
59 | const forwardsMatches = getForwardsMatches(transcript, tokens);
60 |
61 | expect(forwardsMatches).toEqual([]);
62 | });
63 |
64 | it('returns multiple matches', () => {
65 | const tokens = ['and', 'fine', 'jaw'];
66 |
67 | const forwardsMatches = getForwardsMatches(transcript, tokens);
68 |
69 | expect(forwardsMatches).toEqual([
70 | { index: 6, length: 1 },
71 | { index: 10, length: 1 },
72 | ]);
73 | });
74 |
75 | it('it returns different length matches', () => {
76 | const tokens = ['and', 'find'];
77 |
78 | const forwardsMatches = getForwardsMatches(transcript, tokens);
79 |
80 | expect(forwardsMatches).toEqual([
81 | { index: 6, length: 2 },
82 | { index: 10, length: 1 },
83 | ]);
84 | });
85 | });
86 |
87 | describe('getBackwardsMatches', () => {
88 | it('returns no matches', () => {
89 | const tokens = ['and', 'fine', 'jaw'];
90 |
91 | const backwardsMatches = getBackwardsMatches(transcript, tokens);
92 |
93 | expect(backwardsMatches).toEqual([]);
94 | });
95 |
96 | it('returns multiple matches', () => {
97 | const tokens = ['something', 'whatever', 'and'];
98 |
99 | const backwardsMatches = getBackwardsMatches(transcript, tokens);
100 |
101 | expect(backwardsMatches).toEqual([
102 | { index: 6, length: 1 },
103 | { index: 10, length: 1 },
104 | ]);
105 | });
106 |
107 | it('returns different length matches', () => {
108 | const tokens = ['it', 'something', 'teeth', 'and'];
109 |
110 | const backwardsMatches = getBackwardsMatches(transcript, tokens);
111 |
112 | expect(backwardsMatches).toEqual([
113 | { index: 5, length: 2 },
114 | { index: 10, length: 1 },
115 | ]);
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | import TranscriptDisplay from './TranscriptDisplay';
4 | import Instructions from './Instructions';
5 | import matchCorrection from '../helpers/matchCorrection';
6 | import applyCorrection from '../helpers/applyCorrection';
7 | import isMatchComplete from '../helpers/isMatchComplete';
8 |
9 | class App extends Component {
10 | constructor(props) {
11 | super(props);
12 |
13 | this.state = {
14 | transcript: props.transcript,
15 | audio: props.audio,
16 | inputValue: '',
17 | correctionWindow: 5,
18 | playing: false,
19 | currentTime: 0,
20 | segments: {
21 | uncorrectablePlayedWords: [],
22 | correctablePlayedWords: [],
23 | unplayedWords: props.transcript,
24 | },
25 | match: null,
26 | };
27 |
28 | this.handleEnter = this.handleEnter.bind(this);
29 | this.handleInputChange = this.handleInputChange.bind(this);
30 | }
31 |
32 | componentDidMount() {
33 | this.player.addEventListener('playing', () => {
34 | this.setState({
35 | playing: true,
36 | });
37 | this.input.focus();
38 | });
39 |
40 | this.player.addEventListener('pause', () => {
41 | this.setState({
42 | playing: false,
43 | });
44 | });
45 |
46 | this.player.addEventListener('timeupdate', (e) => {
47 | this.setState({
48 | currentTime: e.target.currentTime,
49 | });
50 | });
51 | }
52 |
53 | handleEnter(event) {
54 | event.preventDefault();
55 |
56 | if (this.state.inputValue) {
57 | if (this.state.match && isMatchComplete(this.state.match)) {
58 | this.setState({
59 | transcript: applyCorrection(
60 | this.state.transcript,
61 | this.state.transcript
62 | .filter(word => word.end < this.state.currentTime - this.state.correctionWindow)
63 | .length,
64 | this.state.match
65 | ),
66 | match: null,
67 | inputValue: '',
68 | });
69 |
70 | this.player.play();
71 | }
72 | } else if (this.state.playing) {
73 | this.player.pause();
74 | } else {
75 | this.player.play();
76 | }
77 | }
78 |
79 | handleInputChange(e) {
80 | this.player.pause();
81 |
82 | const inputValue = e.target.value;
83 |
84 | const match = matchCorrection(
85 | this.state.transcript
86 | .filter(word =>
87 | word.start <= this.state.currentTime
88 | && word.end > this.state.currentTime - this.state.correctionWindow
89 | )
90 | .map(w => w.text),
91 | inputValue
92 | );
93 |
94 | this.setState({ inputValue, match });
95 | }
96 |
97 | render() {
98 | return (
99 |
100 |
Overtyper
101 |
{ this.player = player; }}
106 | />
107 | Transcript
108 |
114 | Correction
115 |
125 |
126 | ★
127 |
128 | Instructions
129 |
130 |
131 | );
132 | }
133 | }
134 |
135 | export default App;
136 |
--------------------------------------------------------------------------------
/src/components/Instructions.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Instructions = () => (
4 |
5 |
6 | Press play on media controls to start playback. The cursor will automatically appear in the correction input field.
7 | Listen to the audio. Words in the transcript will be highlighted as they are spoken.
8 | If you come across a point where the transcript doesn't match the audio, start typing a correction in the input field. Playback will automatically be paused. You must type the words immediately preceding and following the correction you wish to apply. For example, to change the text "She had a find your line" to "She had a fine jaw line", type "a fine jaw line". A preview of your how correction your will be applied will appear inline in the transcript.
9 | When you are happy with the correction press [Enter] to apply it. The transcript will be updated and playback will start again.
10 | Repeat the process for every subsequent error.
11 |
12 |
13 | );
14 |
15 | export default Instructions;
16 |
--------------------------------------------------------------------------------
/src/components/TranscriptDisplay.css:
--------------------------------------------------------------------------------
1 | .match_start, .match_end {
2 | font-weight: bold;
3 | }
4 |
5 | .replaced {
6 | text-decoration: line-through;
7 | }
8 |
9 | .replacement {
10 | font-style: italic;
11 | }
12 |
13 | .transcriptDisplay--played {
14 |
15 | }
16 |
17 | .transcriptDisplay--played_uncorrectable {
18 |
19 | }
20 |
21 | .transcriptDisplay--played_correctable {
22 | background: #ddd;
23 | }
24 |
25 | .transcriptDisplay--unplayed {
26 | color: #cccccc;
27 | }
--------------------------------------------------------------------------------
/src/components/TranscriptDisplay.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './TranscriptDisplay.css';
4 |
5 | const SPAN_TYPES = {
6 | MATCH_START: 'match_start',
7 | MATCH_END: 'match_end',
8 | REPLACED: 'replaced',
9 | REPLACEMENT: 'replacement',
10 | };
11 |
12 | const CorrectionWindow = ({ correctablePlayedWords, match }) => {
13 | if (match) {
14 | const words = correctablePlayedWords.map(word => word.text);
15 |
16 | const parts = [];
17 |
18 | if (match) {
19 | parts.push(words.slice(0, match.start.index).join(' '));
20 | parts.push(
21 |
22 | {words.slice(match.start.index, match.start.index + match.start.length).join(' ')}
23 |
24 | );
25 |
26 | if (match.replacement) {
27 | if (match.end) {
28 | parts.push(
29 |
30 | {words.slice(match.start.index + match.start.length, match.end.index).join(' ')}
31 |
32 | );
33 | parts.push(
34 |
35 | {match.replacement}
36 |
37 | );
38 | parts.push(
39 |
40 | {words.slice(match.end.index, match.end.index + match.end.length).join(' ')}
41 |
42 | );
43 | parts.push(
44 | words.slice(match.end.index + match.end.length).join(' ')
45 | );
46 | } else {
47 | parts.push(
48 |
49 | {match.replacement}
50 |
51 | );
52 | parts.push(
53 | words.slice(match.start.index + match.start.length).join(' ')
54 | );
55 | }
56 | } else {
57 | parts.push(words.slice(match.start.index + match.start.length, words.length).join(' '));
58 | }
59 | } else {
60 | parts.push(words.join(' '));
61 | }
62 |
63 | return (
64 |
65 | {parts.reduce((prev, curr) => [prev, ' ', curr])}
66 |
67 | );
68 | }
69 |
70 | return (
71 |
72 | {correctablePlayedWords.map(word => word.text).join(' ')}
73 |
74 | );
75 | };
76 |
77 | const TranscriptDisplay = ({ transcript, currentTime, correctionWindow, match }) => {
78 | const playedWords = transcript
79 | .filter(word => word.start <= currentTime);
80 |
81 | const uncorrectablePlayedWords = playedWords
82 | .filter(word => word.end < currentTime - correctionWindow);
83 |
84 | const correctablePlayedWords = playedWords
85 | .filter(word => word.end >= currentTime - correctionWindow);
86 |
87 | const unplayedWords = transcript
88 | .filter(word => word.start > currentTime);
89 |
90 | return (
91 |
92 |
93 |
94 | {uncorrectablePlayedWords.map(word => word.text).join(' ')}
95 |
96 | {' '}
97 |
101 |
102 | {' '}
103 |
104 | {unplayedWords.map(word => word.text).join(' ')}
105 |
106 |
107 | );
108 | };
109 |
110 | export default TranscriptDisplay;
111 |
112 | export {
113 | CorrectionWindow,
114 | };
115 |
--------------------------------------------------------------------------------
/src/helpers/applyCorrection.js:
--------------------------------------------------------------------------------
1 | const applyCorrection = (transcript, windowStart, correction) => {
2 | if (!correction) {
3 | return transcript;
4 | }
5 |
6 | const replacementWords = correction.replacement.split(' ');
7 |
8 | const replacedLength = correction.end.index - correction.start.index - correction.start.length;
9 |
10 | if (replacementWords.length === replacedLength) {
11 | const correctedTranscript = transcript.slice(0);
12 |
13 | replacementWords.forEach((replacementText, index) => {
14 | const word = correctedTranscript[
15 | windowStart + correction.start.index + correction.start.length + index
16 | ];
17 |
18 | correctedTranscript[
19 | windowStart + correction.start.index + correction.start.length + index
20 | ] = Object.assign({}, word, { text: replacementText });
21 | });
22 |
23 | return correctedTranscript;
24 | }
25 |
26 | return transcript.slice(0, windowStart + correction.start.index + correction.start.length)
27 | .concat([{
28 | text: correction.replacement,
29 | start: transcript[windowStart + correction.start.index + correction.start.length].start,
30 | end: transcript[(windowStart + correction.end.index) - 1].end,
31 | }])
32 | .concat(transcript.slice(windowStart + correction.end.index));
33 | };
34 |
35 | export default applyCorrection;
36 |
--------------------------------------------------------------------------------
/src/helpers/isMatchComplete.js:
--------------------------------------------------------------------------------
1 | const isMatchComplete = match => (
2 | (match
3 | && match.start
4 | && match.replacement
5 | && match.replacement.length > 0
6 | && match.end
7 | && true
8 | ) || false
9 | );
10 |
11 | export default isMatchComplete;
12 |
--------------------------------------------------------------------------------
/src/helpers/matchCorrection.js:
--------------------------------------------------------------------------------
1 | const normaliseToken = input => input;
2 |
3 | const getTokens = input => (
4 | input
5 | .trim()
6 | .split(' ')
7 | .map(normaliseToken)
8 | );
9 |
10 | const getForwardsMatches = (transcript, tokens) => {
11 | const matchRoots = transcript
12 | .map((word, index) => ({ word, index }))
13 | .filter(({ word }) => word === tokens[0])
14 | .map(({ index }) => ({ index, length: 1 }));
15 |
16 | return matchRoots.map((initialMatch) => {
17 | let finished = false;
18 |
19 | const match = initialMatch;
20 |
21 | for (let i = 1; !finished && i < tokens.length; i += 1) {
22 | if (transcript[match.index + i] === tokens[i]) {
23 | match.length += 1;
24 | } else {
25 | finished = true;
26 | }
27 | }
28 |
29 | return match;
30 | });
31 | };
32 |
33 | const getBackwardsMatches = (transcript, tokens) => {
34 | const matchRoots = transcript
35 | .map((word, index) => ({ word, index }))
36 | .filter(({ word }) => word === tokens[tokens.length - 1])
37 | .map(({ index }) => ({ index, length: 1 }));
38 |
39 | return matchRoots.map((initialMatch) => {
40 | let finished = false;
41 |
42 | const match = initialMatch;
43 |
44 | for (let i = 1; !finished && i < tokens.length; i += 1) {
45 | if (transcript[match.index - i] === tokens[tokens.length - i - 1]) {
46 | match.length += 1;
47 | } else {
48 | finished = true;
49 | }
50 | }
51 |
52 | return {
53 | length: match.length,
54 | index: (match.index - match.length) + 1,
55 | };
56 | });
57 | };
58 |
59 | const matchCorrection = (transcript, correction) => {
60 | const tokens = getTokens(correction);
61 |
62 | // Get forwards matches
63 | const forwardsMatches = getForwardsMatches(transcript, tokens);
64 |
65 | // If there are forwards matches
66 | if (forwardsMatches.length > 0) {
67 | let start;
68 | let end = null;
69 | let replacement = null;
70 |
71 | if (forwardsMatches.length === 1) {
72 | start = forwardsMatches[0];
73 | } else {
74 | // Sort by length
75 | const sortedForwardsMatches = forwardsMatches.sort(
76 | (a, b) => b.length - a.length,
77 | );
78 |
79 | // Get longest forward matches
80 | const longestForwardsMatches = sortedForwardsMatches.filter(
81 | match => match.length === sortedForwardsMatches[0].length
82 | );
83 |
84 | // If there is more than one
85 | if (longestForwardsMatches.length > 0) {
86 | // Choose the first one
87 | start = longestForwardsMatches
88 | .sort((a, b) => a.index - b.index)[0];
89 | } else {
90 | // Otherwise we have our longest match
91 | start = longestForwardsMatches[0];
92 | }
93 | }
94 |
95 | // Get backwards matches
96 | const backwardsMatches = getBackwardsMatches(transcript, tokens.slice(start.length));
97 |
98 | // If there are backwards matches
99 | if (backwardsMatches.length > 0) {
100 | if (backwardsMatches.length === 1) {
101 | end = backwardsMatches[0];
102 | } else {
103 | // Sort by length
104 | const sortedBackwardsMatches = backwardsMatches.sort(
105 | (a, b) => b.length - a.length,
106 | );
107 |
108 | // Get longest backwards matches
109 | const longestBackwardsMatches = sortedBackwardsMatches.filter(
110 | match => match.length === sortedBackwardsMatches[0].length
111 | );
112 |
113 | // If there is more than one
114 | if (longestBackwardsMatches.length > 0) {
115 | // Choose the first one
116 | end = longestBackwardsMatches
117 | .sort((a, b) => a.index - b.index)[0];
118 | } else {
119 | // Otherwise we have our longest match
120 | end = longestBackwardsMatches[0];
121 | }
122 | }
123 | }
124 |
125 | if (start.length < tokens.length) {
126 | replacement = tokens
127 | .slice(start.length, end ? tokens.length - end.length : tokens.length)
128 | .join(' ');
129 | }
130 |
131 | return {
132 | start,
133 | end,
134 | replacement,
135 | };
136 | }
137 |
138 | return null;
139 | };
140 |
141 | export default matchCorrection;
142 |
143 | export {
144 | getTokens,
145 | getForwardsMatches,
146 | getBackwardsMatches,
147 | };
148 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 10px;
3 | font-size: 18px;
4 | }
5 |
6 | h2 {
7 | font-size: 20px;
8 | font-weight: 600;
9 | margin-top: 20px;
10 | }
11 |
12 | audio {
13 | width: 100%;
14 | }
15 |
16 | .container {
17 | max-width: 700px;
18 | }
19 |
20 | .textInput {
21 | color: #4a4a4a;
22 | font-size: 18px;
23 | width: 100%;
24 | border-width: 0 0 1px 0;
25 | border-color: #727272;
26 | text-align: center;
27 | margin: 10px 0 40px;
28 | }
29 |
30 | .textInput:focus {
31 | outline-width: 0;
32 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import 'bulma/css/bulma.css';
5 | import './index.css';
6 |
7 | import App from './components/App';
8 |
9 | const transcript = require('./media/transcript.json');
10 | const audio = require('./media/audio.mp3');
11 |
12 | ReactDOM.render(
13 | ,
17 | document.getElementById('root')
18 | );
19 |
--------------------------------------------------------------------------------
/src/media/audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alexnorton/overtyper/9dcd3c8e0bbb401ef785cf3f808811477aced8ea/src/media/audio.mp3
--------------------------------------------------------------------------------
/src/media/transcript.json:
--------------------------------------------------------------------------------
1 | [
2 | { "text": "He", "start": 0.05, "end": 0.64 },
3 | { "text": "was", "start": 0.65, "end": 0.96 },
4 | { "text": "the", "start": 0.96, "end": 1.06 },
5 | { "text": "head", "start": 1.06, "end": 1.3900000000000001 },
6 | { "text": "of", "start": 1.39, "end": 1.5299999999999998 },
7 | { "text": "diagnostic", "start": 1.53, "end": 2.18 },
8 | { "text": "medicine", "start": 2.18, "end": 2.5700000000000003 },
9 | { "text": "is", "start": 3.7, "end": 4 },
10 | { "text": "remarkable", "start": 4.01, "end": 4.82 },
11 | { "text": "notion", "start": 4.85, "end": 5.33 },
12 | { "text": "remarkable", "start": 5.37, "end": 5.91 },
13 | { "text": "character", "start": 5.91, "end": 6.470000000000001 },
14 | { "text": "more", "start": 7.11, "end": 7.36 },
15 | { "text": "remarkable", "start": 7.36, "end": 7.800000000000001 },
16 | { "text": "then", "start": 7.8, "end": 8.22 },
17 | { "text": "than", "start": 8.22, "end": 8.41 },
18 | { "text": "now.", "start": 8.42, "end": 8.82 },
19 | { "text": "I", "start": 8.82, "end": 8.950000000000001 },
20 | { "text": "think", "start": 8.95, "end": 9.33 },
21 | { "text": "in", "start": 9.36, "end": 9.75 },
22 | { "text": "that", "start": 9.78, "end": 10.28 },
23 | { "text": "up", "start": 10.79, "end": 11.01 },
24 | { "text": "until", "start": 11.01, "end": 11.39 },
25 | { "text": "that", "start": 11.39, "end": 11.600000000000001 },
26 | { "text": "point", "start": 11.6, "end": 11.969999999999999 },
27 | { "text": "heroes", "start": 11.97, "end": 12.98 },
28 | { "text": "in", "start": 13.29, "end": 13.459999999999999 },
29 | { "text": "television", "start": 13.46, "end": 13.91 },
30 | { "text": "drama", "start": 13.91, "end": 14.370000000000001 },
31 | { "text": "had", "start": 14.37, "end": 14.83 },
32 | { "text": "beautiful", "start": 15.28, "end": 15.75 },
33 | { "text": "hair.", "start": 15.76, "end": 16.19 },
34 | { "text": "Lots", "start": 16.22, "end": 16.49 },
35 | { "text": "of", "start": 16.49, "end": 16.66 },
36 | { "text": "it", "start": 16.66, "end": 16.93 },
37 | { "text": "splendid", "start": 17.1, "end": 17.93 },
38 | { "text": "teeth", "start": 17.93, "end": 18.33 },
39 | { "text": "and", "start": 18.38, "end": 18.619999999999997 },
40 | { "text": "find", "start": 18.62, "end": 19.040000000000003 },
41 | { "text": "your", "start": 19.04, "end": 19.24 },
42 | { "text": "lines", "start": 19.24, "end": 19.86 },
43 | { "text": "and", "start": 20.13, "end": 20.77 },
44 | { "text": "had", "start": 20.77, "end": 20.919999999999998 },
45 | { "text": "to", "start": 20.92, "end": 21.040000000000003 },
46 | { "text": "find", "start": 21.04, "end": 21.46 },
47 | { "text": "something", "start": 21.46, "end": 22 },
48 | { "text": "so", "start": 22, "end": 22.52 },
49 | { "text": "jagged", "start": 23.02, "end": 23.79 },
50 | { "text": "suffices", "start": 23.9, "end": 24.38 },
51 | { "text": "misfits", "start": 24.47, "end": 25.09 },
52 | { "text": "such", "start": 25.26, "end": 25.46 },
53 | { "text": "a", "start": 25.46, "end": 25.52 },
54 | { "text": "misanthrope, ", "start": 25.52, "end": 26.349999999999998 },
55 | { "text": "such", "start": 26.35, "end": 26.6 },
56 | { "text": "a", "start": 26.6, "end": 26.89 },
57 | { "text": "tortured, ", "start": 27.17, "end": 27.970000000000002 },
58 | { "text": "dark, ", "start": 27.97, "end": 28.48 },
59 | { "text": "sarcastic", "start": 28.48, "end": 29.37 },
60 | { "text": "character", "start": 29.62, "end": 30 },
61 | { "text": "at", "start": 30, "end": 30.07 },
62 | { "text": "the", "start": 30.07, "end": 30.16 },
63 | { "text": "centre", "start": 30.16, "end": 30.56 },
64 | { "text": "of", "start": 30.56, "end": 30.63 },
65 | { "text": "the", "start": 30.62, "end": 30.68 },
66 | { "text": "drum", "start": 30.69, "end": 31.040000000000003 },
67 | { "text": "was", "start": 31.46, "end": 31.77 },
68 | { "text": "then.", "start": 31.77, "end": 32.36 },
69 | { "text": "Very", "start": 32.62, "end": 33.01 },
70 | { "text": "unusual.", "start": 33.01, "end": 33.629999999999995 },
71 | { "text": "It's", "start": 33.63, "end": 33.85 },
72 | { "text": "now.", "start": 33.85, "end": 34.35 }
73 | ]
74 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import 'jest-enzyme';
2 | import { configure } from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 |
5 | configure({ adapter: new Adapter() });
6 |
--------------------------------------------------------------------------------