├── Makefile ├── README.md ├── install-and-test.sh ├── moby dick (c1).txt ├── mpv-shot0001.jpg ├── text2media.py ├── txt_hook.conf └── txt_hook.lua /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | cp txt_hook.lua ~/.config/mpv/scripts/ 3 | cp txt_hook.conf ~/.config/mpv/lua-settings/ 4 | cp text2media.py ~/.config/mpv/ 5 | chmod +x ~/.config/mpv/text2media.py 6 | 7 | test: 8 | mpv moby\ dick\ \(c1\).txt 9 | 10 | test-clean: 11 | rm -rf /tmp/mpv-txt/moby\ dick\ \(c1\) 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-txt 2 | A script for the MPV media player that allows you to play text files and ebooks using [text-to-speech (TTS)](https://en.wikipedia.org/wiki/Speech_synthesis). 3 | Currently supported TTS engines are `say` (MacOS) and [`espeak`](https://en.wikipedia.org/wiki/ESpeakNG). Supported formats are txt, epub, mobi, azw3, azw4, pdf, docx, odt, [and many more.](https://manual.calibre-ebook.com/generated/en/ebook-convert.html) 4 | 5 | ![screenshot](mpv-shot0001.jpg) 6 | 7 | ## Dependancies 8 | - MacOS, Linux, \*BSD, etc **(Windows is NOT currently supported)** 9 | - [python3](http://docs.python-guide.org/en/latest/starting/installation/) 10 | - ffmpeg 11 | - [espeak](https://en.wikipedia.org/wiki/ESpeakNG) *(not required on MacOS)* 12 | - [Calibre](https://calibre-ebook.com/) *(OPTIONAL: only required for ebook support)* 13 | 14 | ## Installation 15 | make install 16 | or: 17 | 18 | cp txt_hook.lua ~/.config/mpv/scripts/ 19 | cp text2media.py ~/.config/mpv/ 20 | chmod +x ~/.config/mpv/text2media.py 21 | optional: 22 | 23 | cp txt_hook.conf ~/.config/mpv/lua-settings/ 24 | 25 | ## Technical Details 26 | *mpv-txt* works by splitting the input file into individual sentences, creating a TTS audio file from each sentence, generating an SRT subtitle file from each sentence, then using ffmpeg to combine those audio and SRT files into one mp4 per sentence *(there is no video stream)*. Then ffmpeg is again used to combine all the per-sentence mp4 files into a single mp4. All resulting mp4 files will be located in `/tmp/mpv-txt/`. 27 | 28 | If the input file is an ebook, it is first run through the [`ebook-convert`](https://manual.calibre-ebook.com/generated/en/ebook-convert.html) tool from Calibre to create a text document. 29 | 30 | The output for a reasonably average book weighs in at around 100MB. On modest processors, [a large book like Moby Dick](http://commonplacebook.com/art/books/word-count-for-famous-novels/) may take an hour to run through TTS (Moby Dick takes about 30 minutes on my 1.6GHz i5, using MacOS's `say` and threads=4 and produces a 174MB mp4). If the product files are still present in `/tmp/mpv-txt/` then *mpv-txt* will not regenerate them. However you may want to copy the final product mp4 out of `/tmp/mpv-txt/` and store it someplace more permanent, so you don't need to waste your time recreating it in the future. 31 | 32 | If you quit MPV while *mpv-txt* is in the middle of processing a file, the next time you try to play that file *mpv-txt* will resume where you left off. 33 | 34 | ## Plans For Future Enhancement 35 | *(some of these plans may prove to be mutually exclusive)* 36 | - [x] ~~epub/mobi support (using [Calibre's](https://en.wikipedia.org/wiki/Calibre_(software)) `ebook-convert` to generate a text file)~~ 37 | - [ ] give attention to subtitle formatting (currently all default settings are used) 38 | - [ ] add a text substitution feature, allowing users to manipulate TTS pronounciations of difficult words 39 | - [x] ~~parallel TTS~~ 40 | - [ ] remove python3 dependancy(?) 41 | - [ ] windows support (low priority, but reasonable pull requests are welcome ;) ) 42 | - [ ] support for 'cloud' TTS services (VERY low priority) 43 | -------------------------------------------------------------------------------- /install-and-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | make install ; make test-clean ; make test 3 | -------------------------------------------------------------------------------- /moby dick (c1).txt: -------------------------------------------------------------------------------- 1 | CHAPTER 1. Loomings. 2 | 3 | Call me Ishmael. Some years ago—never mind how long precisely—having 4 | little or no money in my purse, and nothing particular to interest me 5 | on shore, I thought I would sail about a little and see the watery part 6 | of the world. It is a way I have of driving off the spleen and 7 | regulating the circulation. Whenever I find myself growing grim about 8 | the mouth; whenever it is a damp, drizzly November in my soul; whenever 9 | I find myself involuntarily pausing before coffin warehouses, and 10 | bringing up the rear of every funeral I meet; and especially whenever 11 | my hypos get such an upper hand of me, that it requires a strong moral 12 | principle to prevent me from deliberately stepping into the street, and 13 | methodically knocking people's hats off—then, I account it high time to 14 | get to sea as soon as I can. This is my substitute for pistol and ball. 15 | With a philosophical flourish Cato throws himself upon his sword; I 16 | quietly take to the ship. There is nothing surprising in this. If they 17 | but knew it, almost all men in their degree, some time or other, 18 | cherish very nearly the same feelings towards the ocean with me. 19 | 20 | There now is your insular city of the Manhattoes, belted round by 21 | wharves as Indian isles by coral reefs—commerce surrounds it with her 22 | surf. Right and left, the streets take you waterward. Its extreme 23 | downtown is the battery, where that noble mole is washed by waves, and 24 | cooled by breezes, which a few hours previous were out of sight of 25 | land. Look at the crowds of water-gazers there. 26 | 27 | Circumambulate the city of a dreamy Sabbath afternoon. Go from Corlears 28 | Hook to Coenties Slip, and from thence, by Whitehall, northward. What 29 | do you see?—Posted like silent sentinels all around the town, stand 30 | thousands upon thousands of mortal men fixed in ocean reveries. Some 31 | leaning against the spiles; some seated upon the pier-heads; some 32 | looking over the bulwarks of ships from China; some high aloft in the 33 | rigging, as if striving to get a still better seaward peep. But these 34 | are all landsmen; of week days pent up in lath and plaster—tied to 35 | counters, nailed to benches, clinched to desks. How then is this? Are 36 | the green fields gone? What do they here? 37 | 38 | But look! here come more crowds, pacing straight for the water, and 39 | seemingly bound for a dive. Strange! Nothing will content them but the 40 | extremest limit of the land; loitering under the shady lee of yonder 41 | warehouses will not suffice. No. They must get just as nigh the water 42 | as they possibly can without falling in. And there they stand—miles of 43 | them—leagues. Inlanders all, they come from lanes and alleys, streets 44 | and avenues—north, east, south, and west. Yet here they all unite. Tell 45 | me, does the magnetic virtue of the needles of the compasses of all 46 | those ships attract them thither? 47 | 48 | Once more. Say you are in the country; in some high land of lakes. Take 49 | almost any path you please, and ten to one it carries you down in a 50 | dale, and leaves you there by a pool in the stream. There is magic in 51 | it. Let the most absent-minded of men be plunged in his deepest 52 | reveries—stand that man on his legs, set his feet a-going, and he will 53 | infallibly lead you to water, if water there be in all that region. 54 | Should you ever be athirst in the great American desert, try this 55 | experiment, if your caravan happen to be supplied with a metaphysical 56 | professor. Yes, as every one knows, meditation and water are wedded for 57 | ever. 58 | 59 | But here is an artist. He desires to paint you the dreamiest, shadiest, 60 | quietest, most enchanting bit of romantic landscape in all the valley 61 | of the Saco. What is the chief element he employs? There stand his 62 | trees, each with a hollow trunk, as if a hermit and a crucifix were 63 | within; and here sleeps his meadow, and there sleep his cattle; and up 64 | from yonder cottage goes a sleepy smoke. Deep into distant woodlands 65 | winds a mazy way, reaching to overlapping spurs of mountains bathed in 66 | their hill-side blue. But though the picture lies thus tranced, and 67 | though this pine-tree shakes down its sighs like leaves upon this 68 | shepherd's head, yet all were vain, unless the shepherd's eye were 69 | fixed upon the magic stream before him. Go visit the Prairies in June, 70 | when for scores on scores of miles you wade knee-deep among 71 | Tiger-lilies—what is the one charm wanting?—Water—there is not a drop 72 | of water there! Were Niagara but a cataract of sand, would you travel 73 | your thousand miles to see it? Why did the poor poet of Tennessee, upon 74 | suddenly receiving two handfuls of silver, deliberate whether to buy 75 | him a coat, which he sadly needed, or invest his money in a pedestrian 76 | trip to Rockaway Beach? Why is almost every robust healthy boy with a 77 | robust healthy soul in him, at some time or other crazy to go to sea? 78 | Why upon your first voyage as a passenger, did you yourself feel such a 79 | mystical vibration, when first told that you and your ship were now out 80 | of sight of land? Why did the old Persians hold the sea holy? Why did 81 | the Greeks give it a separate deity, and own brother of Jove? Surely 82 | all this is not without meaning. And still deeper the meaning of that 83 | story of Narcissus, who because he could not grasp the tormenting, mild 84 | image he saw in the fountain, plunged into it and was drowned. But that 85 | same image, we ourselves see in all rivers and oceans. It is the image 86 | of the ungraspable phantom of life; and this is the key to it all. 87 | 88 | Now, when I say that I am in the habit of going to sea whenever I begin 89 | to grow hazy about the eyes, and begin to be over conscious of my 90 | lungs, I do not mean to have it inferred that I ever go to sea as a 91 | passenger. For to go as a passenger you must needs have a purse, and a 92 | purse is but a rag unless you have something in it. Besides, passengers 93 | get sea-sick—grow quarrelsome—don't sleep of nights—do not enjoy 94 | themselves much, as a general thing;—no, I never go as a passenger; 95 | nor, though I am something of a salt, do I ever go to sea as a 96 | Commodore, or a Captain, or a Cook. I abandon the glory and distinction 97 | of such offices to those who like them. For my part, I abominate all 98 | honorable respectable toils, trials, and tribulations of every kind 99 | whatsoever. It is quite as much as I can do to take care of myself, 100 | without taking care of ships, barques, brigs, schooners, and what not. 101 | And as for going as cook,—though I confess there is considerable glory 102 | in that, a cook being a sort of officer on ship-board—yet, somehow, I 103 | never fancied broiling fowls;—though once broiled, judiciously 104 | buttered, and judgmatically salted and peppered, there is no one who 105 | will speak more respectfully, not to say reverentially, of a broiled 106 | fowl than I will. It is out of the idolatrous dotings of the old 107 | Egyptians upon broiled ibis and roasted river horse, that you see the 108 | mummies of those creatures in their huge bake-houses the pyramids. 109 | 110 | No, when I go to sea, I go as a simple sailor, right before the mast, 111 | plumb down into the forecastle, aloft there to the royal mast-head. 112 | True, they rather order me about some, and make me jump from spar to 113 | spar, like a grasshopper in a May meadow. And at first, this sort of 114 | thing is unpleasant enough. It touches one's sense of honor, 115 | particularly if you come of an old established family in the land, the 116 | Van Rensselaers, or Randolphs, or Hardicanutes. And more than all, if 117 | just previous to putting your hand into the tar-pot, you have been 118 | lording it as a country schoolmaster, making the tallest boys stand in 119 | awe of you. The transition is a keen one, I assure you, from a 120 | schoolmaster to a sailor, and requires a strong decoction of Seneca and 121 | the Stoics to enable you to grin and bear it. But even this wears off 122 | in time. 123 | 124 | What of it, if some old hunks of a sea-captain orders me to get a broom 125 | and sweep down the decks? What does that indignity amount to, weighed, 126 | I mean, in the scales of the New Testament? Do you think the archangel 127 | Gabriel thinks anything the less of me, because I promptly and 128 | respectfully obey that old hunks in that particular instance? Who ain't 129 | a slave? Tell me that. Well, then, however the old sea-captains may 130 | order me about—however they may thump and punch me about, I have the 131 | satisfaction of knowing that it is all right; that everybody else is 132 | one way or other served in much the same way—either in a physical or 133 | metaphysical point of view, that is; and so the universal thump is 134 | passed round, and all hands should rub each other's shoulder-blades, 135 | and be content. 136 | 137 | Again, I always go to sea as a sailor, because they make a point of 138 | paying me for my trouble, whereas they never pay passengers a single 139 | penny that I ever heard of. On the contrary, passengers themselves must 140 | pay. And there is all the difference in the world between paying and 141 | being paid. The act of paying is perhaps the most uncomfortable 142 | infliction that the two orchard thieves entailed upon us. But _being 143 | paid_,—what will compare with it? The urbane activity with which a man 144 | receives money is really marvellous, considering that we so earnestly 145 | believe money to be the root of all earthly ills, and that on no 146 | account can a monied man enter heaven. Ah! how cheerfully we consign 147 | ourselves to perdition! 148 | 149 | Finally, I always go to sea as a sailor, because of the wholesome 150 | exercise and pure air of the fore-castle deck. For as in this world, 151 | head winds are far more prevalent than winds from astern (that is, if 152 | you never violate the Pythagorean maxim), so for the most part the 153 | Commodore on the quarter-deck gets his atmosphere at second hand from 154 | the sailors on the forecastle. He thinks he breathes it first; but not 155 | so. In much the same way do the commonalty lead their leaders in many 156 | other things, at the same time that the leaders little suspect it. But 157 | wherefore it was that after having repeatedly smelt the sea as a 158 | merchant sailor, I should now take it into my head to go on a whaling 159 | voyage; this the invisible police officer of the Fates, who has the 160 | constant surveillance of me, and secretly dogs me, and influences me in 161 | some unaccountable way—he can better answer than any one else. And, 162 | doubtless, my going on this whaling voyage, formed part of the grand 163 | programme of Providence that was drawn up a long time ago. It came in 164 | as a sort of brief interlude and solo between more extensive 165 | performances. I take it that this part of the bill must have run 166 | something like this: 167 | 168 | “_Grand Contested Election for the Presidency of the United States._ 169 | “WHALING VOYAGE BY ONE ISHMAEL. “BLOODY BATTLE IN AFFGHANISTAN.” 170 | 171 | Though I cannot tell why it was exactly that those stage managers, the 172 | Fates, put me down for this shabby part of a whaling voyage, when 173 | others were set down for magnificent parts in high tragedies, and short 174 | and easy parts in genteel comedies, and jolly parts in farces—though I 175 | cannot tell why this was exactly; yet, now that I recall all the 176 | circumstances, I think I can see a little into the springs and motives 177 | which being cunningly presented to me under various disguises, induced 178 | me to set about performing the part I did, besides cajoling me into the 179 | delusion that it was a choice resulting from my own unbiased freewill 180 | and discriminating judgment. 181 | 182 | Chief among these motives was the overwhelming idea of the great whale 183 | himself. Such a portentous and mysterious monster roused all my 184 | curiosity. Then the wild and distant seas where he rolled his island 185 | bulk; the undeliverable, nameless perils of the whale; these, with all 186 | the attending marvels of a thousand Patagonian sights and sounds, 187 | helped to sway me to my wish. With other men, perhaps, such things 188 | would not have been inducements; but as for me, I am tormented with an 189 | everlasting itch for things remote. I love to sail forbidden seas, and 190 | land on barbarous coasts. Not ignoring what is good, I am quick to 191 | perceive a horror, and could still be social with it—would they let 192 | me—since it is but well to be on friendly terms with all the inmates of 193 | the place one lodges in. 194 | 195 | By reason of these things, then, the whaling voyage was welcome; the 196 | great flood-gates of the wonder-world swung open, and in the wild 197 | conceits that swayed me to my purpose, two and two there floated into 198 | my inmost soul, endless processions of the whale, and, mid most of them 199 | all, one grand hooded phantom, like a snow hill in the air. 200 | -------------------------------------------------------------------------------- /mpv-shot0001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgreco/mpv-txt/a972df2873a25872a5fed54e7756d91ea73840cd/mpv-shot0001.jpg -------------------------------------------------------------------------------- /text2media.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import re, sys, os, argparse, shutil, errno 4 | from subprocess import call, check_output 5 | from multiprocessing import Pool, Process, Queue 6 | from queue import Empty 7 | 8 | progressQ = Queue() 9 | 10 | #shamelessly ripped/adapted from https://stackoverflow.com/questions/4576077/python-split-text-on-sentences/31505798#31505798 11 | #this could be made a lot better 12 | def split_into_sentences(text): 13 | caps = "([A-Z])" 14 | digits = "([0-9])" 15 | prefixes = "(Mr|St|Mrs|Ms|Dr)[.]" 16 | suffixes = "(Inc|Ltd|Jr|Sr|Co)" 17 | starters = "(Mr|Mrs|Ms|Dr|He\s|She\s|It\s|They\s|Their\s|Our\s|We\s|But\s|However\s|That\s|This\s|Wherever)" 18 | acronyms = "([A-Z][.][A-Z][.](?:[A-Z][.])?)" 19 | websites = "[.](com|net|org|io|gov)" 20 | 21 | text = " " + text + " " 22 | text = text.replace("\n"," ") 23 | text = re.sub(prefixes,"\\1",text) 24 | text = re.sub(websites,"\\1",text) 25 | if "Ph.D" in text: text = text.replace("Ph.D.","PhD") 26 | text = re.sub("\s" + caps + "[.] "," \\1 ",text) 27 | text = re.sub(acronyms+" "+starters,"\\1 \\2",text) 28 | text = re.sub(caps + "[.]" + caps + "[.]" + caps + "[.]","\\1\\2\\3",text) 29 | text = re.sub(caps + "[.]" + caps + "[.]","\\1\\2",text) 30 | text = re.sub(" "+suffixes+"[.] "+starters," \\1 \\2",text) 31 | text = re.sub(" "+suffixes+"[.]"," \\1",text) 32 | text = re.sub(" " + caps + "[.]"," \\1",text) 33 | text = re.sub(digits + "[.]" + digits,"\\1\\2",text) 34 | 35 | if "\"" in text: text = text.replace(".\"","\".") 36 | if "!" in text: text = text.replace("!\"","\"!") 37 | if "?" in text: text = text.replace("?\"","\"?") 38 | 39 | if "”" in text: text = text.replace(".”","”.") 40 | if "”" in text: text = text.replace("!”","”!") 41 | if "”" in text: text = text.replace("?”","”?") 42 | 43 | if ")" in text: text = text.replace(".)",").") 44 | if ")" in text: text = text.replace("!)",")!") 45 | if ")" in text: text = text.replace("?)",")?") 46 | 47 | if "]" in text: text = text.replace(".]","].") 48 | if "]" in text: text = text.replace("!]","]!") 49 | if "]" in text: text = text.replace("?]","]?") 50 | 51 | if "'" in text: text = text.replace(".'","'.") 52 | if "'" in text: text = text.replace("!'","'!") 53 | if "'" in text: text = text.replace("?'","'?") 54 | 55 | text = text.replace(".",".") 56 | text = text.replace("!","!") 57 | text = text.replace("?","?") 58 | text = text.replace("",".") 59 | sentences = text.split("") 60 | sentences = sentences[:-1] 61 | sentences = [s.strip() for s in sentences] 62 | return sentences 63 | 64 | calibre_install_locations = [ 65 | "/Applications/calibre.app/Contents/MacOS/ebook-convert", 66 | "~/Applications/calibre.app/Contents/MacOS/ebook-convert"] 67 | #convert ebook to txt: 68 | # various formats supported: https://manual.calibre-ebook.com/generated/en/ebook-convert.html 69 | def ebook_convert(ebook): 70 | out = project+"/"+basename+".txt" 71 | try: 72 | call(["ebook-convert", ebook, out], 73 | stdout=open(os.devnull, 'w')) 74 | except OSError as e: 75 | if e.errno == errno.ENOENT: 76 | calibre_installs = [f for f in calibre_install_locations if os.path.isfile(f)] 77 | if not calibre_installs: 78 | sys.exit("text2media.py could not find `ebook-convert`. Make sure you have Calibre (https://calibre-ebook.com/) installed and `ebook-convert` in your path.") 79 | 80 | call([calibre_installs[0], ebook, out], 81 | stdout=open(os.devnull, 'w')) 82 | else: 83 | raise 84 | 85 | if args.editor_cleanup: 86 | call(["xterm", "-e", os.environ.get('EDITOR', 'nano') + " " + out]) 87 | 88 | return out 89 | 90 | 91 | #try `say` first because quality is better, fall back on `espeak` 92 | def say(text,fragment): 93 | try: 94 | out = project+"/tts-"+str(fragment)+".aiff" 95 | call(["say", "..."+text, "-o", out]) 96 | return out 97 | except OSError as e: 98 | if e.errno == os.errno.ENOENT: 99 | try: 100 | out = project+"/tts-"+str(fragment)+".wav" 101 | call(["espeak", "-w", out, "..."+text ]) 102 | return out 103 | except OSError as e: 104 | if e.errno == os.errno.ENOENT: 105 | sys.exit("text2media.py requires either `say` (OSX) or `espeak` to be in your PATH.") 106 | else: 107 | raise 108 | else: 109 | raise 110 | 111 | 112 | def text_to_mp4(fragment_text): 113 | fragment = fragment_text[0] 114 | text = fragment_text[1] 115 | out=project+"/"+basename+"-"+str(fragment)+".mp4" 116 | 117 | if os.path.isfile(out): 118 | if args.gui_progressbar: 119 | progressQ.put(True) 120 | return out 121 | 122 | #create TTS audio 123 | audio_file = say(text, fragment) 124 | 125 | #create SRT 126 | try: 127 | audio_len = check_output([ 128 | "ffprobe", 129 | "-v", "quiet", 130 | "-show_entries", "format=duration", 131 | "-of", "default=noprint_wrappers=1:nokey=1", 132 | "-sexagesimal", 133 | audio_file]).decode("utf-8").rstrip() 134 | audio_len = re.match("(\d+:\d+:\d+\.\d{1,2})", audio_len).group(1) 135 | except OSError as e: 136 | if e.errno == os.errno.ENOENT: 137 | sys.exit("text2media.py requires ffprobe (from ffmpeg) to be in your PATH.") 138 | else: 139 | raise 140 | 141 | srt_file = project+"/tts-"+str(fragment)+".srt" 142 | with open(srt_file, 'w+', encoding='utf-8') as srt: 143 | srt.write("1\n") 144 | srt.write("00:00:00.000 --> " + audio_len + "\n") 145 | srt.write(text) 146 | 147 | #combine srt and tts audio as an mp4 148 | try: 149 | call(["ffmpeg", "-v", "quiet", 150 | "-i", audio_file, 151 | "-i", srt_file, 152 | "-c:a", "mp3", "-c:s", "mov_text", out]) 153 | os.remove(srt_file) 154 | os.remove(audio_file) 155 | except OSError as e: 156 | if e.errno == os.errno.ENOENT: 157 | sys.exit("text2media.py requires ffmpeg to be in your PATH.") 158 | else: 159 | raise 160 | 161 | if args.gui_progressbar: 162 | progressQ.put(True) 163 | 164 | if not os.path.isabs(project): 165 | return basename+"-"+str(fragment)+".mp4" 166 | return out 167 | 168 | def combine_fragments(fragments): 169 | out = args.output if args.output else project+"/"+basename+".mp4" 170 | if os.path.isfile(out): 171 | return out 172 | 173 | with open(project+"/fragments.txt", 'w', encoding='utf-8') as fragment_list: 174 | for f in fragments: 175 | fragment_list.write("file '%s'\n" % f) 176 | try: 177 | call(["ffmpeg", "-v", "quiet", 178 | "-safe", "0", 179 | "-f", "concat", 180 | "-i", project+"/fragments.txt", 181 | "-c", "copy", 182 | "-c:s", "mov_text", 183 | out]) 184 | except OSError as e: 185 | if e.errno == os.errno.ENOENT: 186 | sys.exit("text2media.py requires ffmpeg to be in your PATH.") 187 | else: 188 | raise 189 | return out 190 | 191 | ap = argparse.ArgumentParser() 192 | ap.add_argument("input_file", type=str, help="text file to process") 193 | ap.add_argument("-t", "--threads", type=int, default=1, help="number of threads to use for TTS") 194 | ap.add_argument("-o", "--output", type=str, help="specify the output path/name") 195 | ap.add_argument("-p", "--project_directory", type=str, help="override the project directory (defaults to a new directory in /tmp/mpv-txt/ )") 196 | ap.add_argument("-x", "--cleanup", action="store_true", help="cleanup all intermediate product files before exiting") 197 | ap.add_argument("-e", "--editor-cleanup", action="store_true", help="launch a text editor to clean up Calibre output") 198 | ap.add_argument("-g", "--gui-progressbar", action="store_true", help="display a GUI progress bar") 199 | args = ap.parse_args() 200 | 201 | basename = os.path.basename(os.path.splitext(args.input_file)[0]) 202 | project = args.project_directory if args.project_directory else "/tmp/mpv-txt/"+basename 203 | os.makedirs(project, exist_ok=True) 204 | 205 | if os.path.splitext(args.input_file)[1] != ".txt": 206 | args.input_file = ebook_convert(args.input_file) 207 | 208 | file_text = open(args.input_file,encoding='utf-8').read()+"." 209 | sentences = split_into_sentences(file_text) 210 | 211 | def generate(): 212 | with Pool(args.threads) as pool: 213 | fragments = pool.map(text_to_mp4, [(i,s) for i,s in enumerate(sentences)]) 214 | 215 | result = combine_fragments(fragments) 216 | if args.gui_progressbar: 217 | progressQ.put(False) 218 | print(result) 219 | 220 | if args.gui_progressbar: 221 | from tkinter import * 222 | from tkinter import ttk 223 | 224 | root = Tk() 225 | root.title("mpv-txt: %s" % basename) 226 | root.resizable(False, False) 227 | root.attributes("-topmost", True) 228 | 229 | txt = Label(root, text="processing fragments (%d of %d)" % (0, len(sentences))) 230 | txt.pack() 231 | 232 | pb = ttk.Progressbar(root, orient="horizontal", length=400, maximum=len(sentences), mode="determinate") 233 | pb.pack() 234 | 235 | gen = Process(target=generate) 236 | gen.start() 237 | 238 | def update(): 239 | while True: 240 | try: 241 | m = progressQ.get_nowait() 242 | if m: 243 | pb["value"] = pb["value"] + 1 244 | txt["text"] = "processing fragments (%d of %d)" % (pb["value"], len(sentences)) 245 | if pb["value"] == len(sentences): 246 | txt["text"] = "combining fragments..." 247 | else: 248 | root.quit() 249 | root.update() 250 | except Empty: 251 | break 252 | except: 253 | raise 254 | root.after(100, update) 255 | 256 | root.after(0, update) 257 | root.mainloop() 258 | else: 259 | generate() 260 | 261 | if args.cleanup: 262 | if not os.path.split(result)[0].startswith(project): 263 | shutil.rmtree(project) 264 | else: 265 | for f in os.listdir(project): 266 | if f != os.path.split(result)[1]: 267 | os.remove(project+"/"+f) 268 | -------------------------------------------------------------------------------- /txt_hook.conf: -------------------------------------------------------------------------------- 1 | # Number of threads to use for text-to-speech processing. 2 | # Note: While threads>1 is worthwhile on MacOS with `say`, it doesn't help as 3 | # much as you might hope. When using `espeak` it helps a lot more. 4 | threads=4 5 | 6 | 7 | # Extensions of documents mpv-txt should attempt to process. 8 | # 9 | # Note: Everything other than "txt" requires Calibre to be installed. 10 | # https://calibre-ebook.com/ 11 | # 12 | # The ebook-convert tool from Calibre is used to convert ebooks to txt 13 | # documents. Many common and obscure input formats are supported, see: 14 | # https://manual.calibre-ebook.com/generated/en/ebook-convert.html 15 | # for more info. 16 | # 17 | # For this to work ebook-convert should be in your PATH. On MacOS 18 | # mpv-txt also looks for ebook-convert in the following locations: 19 | # /Applications/calibre.app/Contents/MacOS/ebook-convert 20 | # ~/Applications/calibre.app/Contents/MacOS/ebook-convert 21 | supported_extensions=["txt", "epub", "mobi", "azw3", "azw4", "pdf", "docx", "odt"] 22 | 23 | 24 | # Optional arguments to pass to ebook-convert. 25 | # refer to: https://manual.calibre-ebook.com/generated/en/ebook-convert.html 26 | ebook_convert_options="" 27 | 28 | 29 | # EXPERIMENTAL! 30 | # If enabled, a text editor will be launched with xterm to cleanup the 31 | # output of Calibre for ebook conversions before it's run through TTS. 32 | # 33 | # Note: $EDITOR will be used if set, otherwise nano. 34 | editor_cleanup=no 35 | 36 | # Display a progress bar during TTS generation. Particularly handy for long 37 | # documents. 38 | gui_progress=yes 39 | -------------------------------------------------------------------------------- /txt_hook.lua: -------------------------------------------------------------------------------- 1 | local utils = require 'mp.utils' 2 | local msg = require 'mp.msg' 3 | 4 | local opts = { 5 | threads = 4, 6 | supported_extensions=[[ 7 | ["txt", "epub", "mobi", "azw3", "azw4", "pdf", "docx", "odt"] 8 | ]], 9 | ebook_convert_options="", 10 | editor_cleanup=false, 11 | gui_progress=true, 12 | } 13 | (require 'mp.options').read_options(opts) 14 | opts.supported_extensions = utils.parse_json(opts.supported_extensions) 15 | 16 | local function exec(args) 17 | local ret = utils.subprocess({args = args}) 18 | return ret.status, ret.stdout, ret, ret.killed_by_us 19 | end 20 | 21 | local function findl(str, patterns) 22 | for i,p in pairs(patterns) do 23 | if str:find("%."..p.."$") then 24 | return true 25 | end 26 | end 27 | return false 28 | end 29 | 30 | mp.add_hook("on_load", 10, function () 31 | local url = mp.get_property("stream-open-filename", "") 32 | msg.debug("stream-open-filename: "..url) 33 | if (findl(url, opts.supported_extensions) == false) then 34 | msg.debug("did not find a supported file") 35 | return 36 | end 37 | 38 | -- find text2media 39 | local text2media_py = mp.find_config_file("text2media.py") 40 | if (text2media_py == nil) then 41 | msg.error("text2media.py is missing, should be in ~/.config/mpv/") 42 | return 43 | end 44 | 45 | -- build text2media command and run 46 | command = {text2media_py, "--cleanup", "--threads", opts.threads, url} 47 | if (opts.editor_cleanup) then table.insert(command, "--editor-cleanup") end 48 | if (opts.gui_progress) then table.insert(command, "--gui-progress") end 49 | stat,out = exec(command) 50 | 51 | mp.set_property("stream-open-filename", out:gsub("\n", "")) 52 | return 53 | end) 54 | --------------------------------------------------------------------------------