├── .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 | 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 |
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 | --------------------------------------------------------------------------------