peteralexandercharles commited on
Commit
98d3f44
·
1 Parent(s): 8514371

Upload 11 files

Browse files
Files changed (11) hide show
  1. COPYING +22 -0
  2. Dockerfile +32 -0
  3. README.md +33 -11
  4. align.py +60 -0
  5. install.sh +11 -0
  6. install_deps.sh +18 -0
  7. install_language_model.sh +3 -0
  8. install_models.sh +17 -0
  9. pylintrc +2 -0
  10. serve.py +274 -0
  11. setup.py +20 -0
COPYING ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Robert M Ochshorn
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
13
+ all 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
21
+ THE SOFTWARE.
22
+
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM ubuntu:18.04
2
+
3
+ RUN DEBIAN_FRONTEND=noninteractive && \
4
+ apt-get update && \
5
+ apt-get install -y \
6
+ gcc g++ gfortran \
7
+ libc++-dev \
8
+ libstdc++-6-dev zlib1g-dev \
9
+ automake autoconf libtool \
10
+ git subversion \
11
+ libatlas3-base \
12
+ nvidia-cuda-dev \
13
+ ffmpeg \
14
+ python3 python3-dev python3-pip \
15
+ python python-dev python-pip \
16
+ wget unzip && \
17
+ apt-get clean
18
+
19
+ ADD ext /gentle/ext
20
+ RUN export MAKEFLAGS=' -j8' && cd /gentle/ext && \
21
+ ./install_kaldi.sh && \
22
+ make depend && make && rm -rf kaldi *.o
23
+
24
+ ADD . /gentle
25
+ RUN cd /gentle && python3 setup.py develop
26
+ RUN cd /gentle && ./install_models.sh
27
+
28
+ EXPOSE 8765
29
+
30
+ VOLUME /gentle/webdata
31
+
32
+ CMD cd /gentle && python3 serve.py
README.md CHANGED
@@ -1,11 +1,33 @@
1
- ---
2
- title: Testsite
3
- emoji: 🌖
4
- colorFrom: indigo
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- license: cc-by-nc-sa-3.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gentle
2
+ **Robust yet lenient forced-aligner built on Kaldi. A tool for aligning speech with text.**
3
+
4
+ ## Getting Started
5
+
6
+ There are three ways to install Gentle.
7
+
8
+ 1. Download the [pre-built Mac application](https://github.com/lowerquality/gentle/releases/latest). This package includes a GUI that will start the server and a browser. It only works on Mac OS.
9
+
10
+ 2. Use the [Docker](https://www.docker.com/) image. Just run ```docker run -P lowerquality/gentle```. This works on all platforms supported by Docker.
11
+
12
+ 3. Download the source code and run ```./install.sh```. Then run ```python3 serve.py``` to start the server. This works on Mac and Linux.
13
+
14
+ ## Using Gentle
15
+
16
+ By default, the aligner listens at http://localhost:8765. That page has a graphical interface for transcribing audio, viewing results, and downloading data.
17
+
18
+ There is also a REST API so you can use Gentle in your programs. Here's an example of how to use the API with CURL:
19
+
20
+ ```bash
21
+ curl -F "[email protected]" -F "[email protected]" "http://localhost:8765/transcriptions?async=false"
22
+ ```
23
+
24
+ If you've downloaded the source code you can also run the aligner as a command line program:
25
+
26
+ ```bash
27
+ git clone https://github.com/lowerquality/gentle.git
28
+ cd gentle
29
+ ./install.sh
30
+ python3 align.py audio.mp3 words.txt
31
+ ```
32
+
33
+ The default behaviour outputs the JSON to stdout. See `python3 align.py --help` for options.
align.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import logging
3
+ import multiprocessing
4
+ import os
5
+ import sys
6
+
7
+ import gentle
8
+
9
+ parser = argparse.ArgumentParser(
10
+ description='Align a transcript to audio by generating a new language model. Outputs JSON')
11
+ parser.add_argument(
12
+ '--nthreads', default=multiprocessing.cpu_count(), type=int,
13
+ help='number of alignment threads')
14
+ parser.add_argument(
15
+ '-o', '--output', metavar='output', type=str,
16
+ help='output filename')
17
+ parser.add_argument(
18
+ '--conservative', dest='conservative', action='store_true',
19
+ help='conservative alignment')
20
+ parser.set_defaults(conservative=False)
21
+ parser.add_argument(
22
+ '--disfluency', dest='disfluency', action='store_true',
23
+ help='include disfluencies (uh, um) in alignment')
24
+ parser.set_defaults(disfluency=False)
25
+ parser.add_argument(
26
+ '--log', default="INFO",
27
+ help='the log level (DEBUG, INFO, WARNING, ERROR, or CRITICAL)')
28
+ parser.add_argument(
29
+ 'audiofile', type=str,
30
+ help='audio file')
31
+ parser.add_argument(
32
+ 'txtfile', type=str,
33
+ help='transcript text file')
34
+ args = parser.parse_args()
35
+
36
+ log_level = args.log.upper()
37
+ logging.getLogger().setLevel(log_level)
38
+
39
+ disfluencies = set(['uh', 'um'])
40
+
41
+ def on_progress(p):
42
+ for k,v in p.items():
43
+ logging.debug("%s: %s" % (k, v))
44
+
45
+
46
+ with open(args.txtfile, encoding="utf-8") as fh:
47
+ transcript = fh.read()
48
+
49
+ resources = gentle.Resources()
50
+ logging.info("converting audio to 8K sampled wav")
51
+
52
+ with gentle.resampled(args.audiofile) as wavfile:
53
+ logging.info("starting alignment")
54
+ aligner = gentle.ForcedAligner(resources, transcript, nthreads=args.nthreads, disfluency=args.disfluency, conservative=args.conservative, disfluencies=disfluencies)
55
+ result = aligner.transcribe(wavfile, progress_cb=on_progress, logging=logging)
56
+
57
+ fh = open(args.output, 'w', encoding="utf-8") if args.output else sys.stdout
58
+ fh.write(result.to_json(indent=2))
59
+ if args.output:
60
+ logging.info("output written to %s" % (args.output))
install.sh ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ git submodule init
6
+ git submodule update
7
+
8
+ ./install_deps.sh
9
+ (cd ext && ./install_kaldi.sh)
10
+ ./install_models.sh
11
+ cd ext && make depend && make
install_deps.sh ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ echo "Installing dependencies..."
6
+
7
+ # Install OS-specific dependencies
8
+ if [[ "$OSTYPE" == "linux-gnu" ]]; then
9
+ apt-get update -qq
10
+ apt-get install -y zlib1g-dev automake autoconf git \
11
+ libtool subversion libatlas3-base python3-pip \
12
+ python3-dev wget unzip python3
13
+ apt-get install -y ffmpeg || echo -n "\n\nYou have to install ffmpeg from a PPA or from https://ffmpeg.org before you can run gentle\n\n"
14
+ python3 setup.py develop
15
+ elif [[ "$OSTYPE" == "darwin"* ]]; then
16
+ brew install ffmpeg libtool automake autoconf wget python3
17
+ sudo python3 setup.py develop
18
+ fi
install_language_model.sh ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ wget -c https://lowerquality.com/gentle/aspire-hclg.tar.gz
2
+ tar -xzvf aspire-hclg.tar.gz
3
+ rm aspire-hclg.tar.gz
install_models.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ VERSION="0.03"
6
+
7
+ download_models() {
8
+ local version="$1"
9
+ local filename="kaldi-models-$version.zip"
10
+ local url="https://lowerquality.com/gentle/$filename"
11
+ wget -O $filename $url
12
+ unzip $filename
13
+ rm $filename
14
+ }
15
+
16
+ echo "Downloading models for v$VERSION..." 1>&2
17
+ download_models $VERSION
pylintrc ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [MESSAGES CONTROL]
2
+ disable=locally-disabled
serve.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from twisted.web.static import File
2
+ from twisted.web.resource import Resource
3
+ from twisted.web.server import Site, NOT_DONE_YET
4
+ from twisted.internet import reactor, threads
5
+ from twisted.web._responses import FOUND
6
+
7
+ import json
8
+ import logging
9
+ import multiprocessing
10
+ import os
11
+ import shutil
12
+ import uuid
13
+ import wave
14
+
15
+ from gentle.util.paths import get_resource, get_datadir
16
+ from gentle.util.cyst import Insist
17
+
18
+ import gentle
19
+
20
+ class TranscriptionStatus(Resource):
21
+ def __init__(self, status_dict):
22
+ self.status_dict = status_dict
23
+ Resource.__init__(self)
24
+
25
+ def render_GET(self, req):
26
+ req.setHeader(b"Content-Type", "application/json")
27
+ return json.dumps(self.status_dict).encode()
28
+
29
+ class Transcriber():
30
+ def __init__(self, data_dir, nthreads=4, ntranscriptionthreads=2):
31
+ self.data_dir = data_dir
32
+ self.nthreads = nthreads
33
+ self.ntranscriptionthreads = ntranscriptionthreads
34
+ self.resources = gentle.Resources()
35
+
36
+ self.full_transcriber = gentle.FullTranscriber(self.resources, nthreads=ntranscriptionthreads)
37
+ self._status_dicts = {}
38
+
39
+ def get_status(self, uid):
40
+ return self._status_dicts.setdefault(uid, {})
41
+
42
+ def out_dir(self, uid):
43
+ return os.path.join(self.data_dir, 'transcriptions', uid)
44
+
45
+ # TODO(maxhawkins): refactor so this is returned by transcribe()
46
+ def next_id(self):
47
+ uid = None
48
+ while uid is None or os.path.exists(os.path.join(self.data_dir, uid)):
49
+ uid = uuid.uuid4().hex[:8]
50
+ return uid
51
+
52
+ def transcribe(self, uid, transcript, audio, async_mode, **kwargs):
53
+
54
+ status = self.get_status(uid)
55
+
56
+ status['status'] = 'STARTED'
57
+ output = {
58
+ 'transcript': transcript
59
+ }
60
+
61
+ outdir = os.path.join(self.data_dir, 'transcriptions', uid)
62
+
63
+ tran_path = os.path.join(outdir, 'transcript.txt')
64
+ with open(tran_path, 'w') as tranfile:
65
+ tranfile.write(transcript)
66
+ audio_path = os.path.join(outdir, 'upload')
67
+ with open(audio_path, 'wb') as wavfile:
68
+ wavfile.write(audio)
69
+
70
+ status['status'] = 'ENCODING'
71
+
72
+ wavfile = os.path.join(outdir, 'a.wav')
73
+ if gentle.resample(os.path.join(outdir, 'upload'), wavfile) != 0:
74
+ status['status'] = 'ERROR'
75
+ status['error'] = "Encoding failed. Make sure that you've uploaded a valid media file."
76
+ # Save the status so that errors are recovered on restart of the server
77
+ # XXX: This won't work, because the endpoint will override this file
78
+ with open(os.path.join(outdir, 'status.json'), 'w') as jsfile:
79
+ json.dump(status, jsfile, indent=2)
80
+ return
81
+
82
+ #XXX: Maybe we should pass this wave object instead of the
83
+ # file path to align_progress
84
+ wav_obj = wave.open(wavfile, 'rb')
85
+ status['duration'] = wav_obj.getnframes() / float(wav_obj.getframerate())
86
+ status['status'] = 'TRANSCRIBING'
87
+
88
+ def on_progress(p):
89
+ print(p)
90
+ for k,v in p.items():
91
+ status[k] = v
92
+
93
+ if len(transcript.strip()) > 0:
94
+ trans = gentle.ForcedAligner(self.resources, transcript, nthreads=self.nthreads, **kwargs)
95
+ elif self.full_transcriber.available:
96
+ trans = self.full_transcriber
97
+ else:
98
+ status['status'] = 'ERROR'
99
+ status['error'] = 'No transcript provided and no language model for full transcription'
100
+ return
101
+
102
+ output = trans.transcribe(wavfile, progress_cb=on_progress, logging=logging)
103
+
104
+ # ...remove the original upload
105
+ os.unlink(os.path.join(outdir, 'upload'))
106
+
107
+ # Save
108
+ with open(os.path.join(outdir, 'align.json'), 'w') as jsfile:
109
+ jsfile.write(output.to_json(indent=2))
110
+ with open(os.path.join(outdir, 'align.csv'), 'w') as csvfile:
111
+ csvfile.write(output.to_csv())
112
+
113
+ # Inline the alignment into the index.html file.
114
+ htmltxt = open(get_resource('www/view_alignment.html')).read()
115
+ htmltxt = htmltxt.replace("var INLINE_JSON;", "var INLINE_JSON=%s;" % (output.to_json()));
116
+ open(os.path.join(outdir, 'index.html'), 'w').write(htmltxt)
117
+
118
+ status['status'] = 'OK'
119
+
120
+ logging.info('done with transcription.')
121
+
122
+ return output
123
+
124
+ class TranscriptionsController(Resource):
125
+ def __init__(self, transcriber):
126
+ Resource.__init__(self)
127
+ self.transcriber = transcriber
128
+
129
+ def getChild(self, uid, req):
130
+ uid = uid.decode()
131
+ out_dir = self.transcriber.out_dir(uid)
132
+ trans_ctrl = File(out_dir)
133
+
134
+ # Add a Status endpoint to the file
135
+ trans_status = TranscriptionStatus(self.transcriber.get_status(uid))
136
+ trans_ctrl.putChild(b"status.json", trans_status)
137
+
138
+ return trans_ctrl
139
+
140
+ def render_POST(self, req):
141
+ uid = self.transcriber.next_id()
142
+
143
+ tran = req.args.get(b'transcript', [b''])[0].decode()
144
+ audio = req.args[b'audio'][0]
145
+
146
+ disfluency = True if b'disfluency' in req.args else False
147
+ conservative = True if b'conservative' in req.args else False
148
+ kwargs = {'disfluency': disfluency,
149
+ 'conservative': conservative,
150
+ 'disfluencies': set(['uh', 'um'])}
151
+
152
+ async_mode = True
153
+ if b'async' in req.args and req.args[b'async'][0] == b'false':
154
+ async_mode = False
155
+
156
+ # We need to make the transcription directory here, so that
157
+ # when we redirect the user we are sure that there's a place
158
+ # for them to go.
159
+ outdir = os.path.join(self.transcriber.data_dir, 'transcriptions', uid)
160
+ os.makedirs(outdir)
161
+
162
+ # Copy over the HTML
163
+ shutil.copy(get_resource('www/view_alignment.html'), os.path.join(outdir, 'index.html'))
164
+
165
+ result_promise = threads.deferToThreadPool(
166
+ reactor, reactor.getThreadPool(),
167
+ self.transcriber.transcribe,
168
+ uid, tran, audio, async_mode, **kwargs)
169
+
170
+ if not async_mode:
171
+ def write_result(result):
172
+ '''Write JSON to client on completion'''
173
+ req.setHeader("Content-Type", "application/json")
174
+ req.write(result.to_json(indent=2).encode())
175
+ req.finish()
176
+ result_promise.addCallback(write_result)
177
+ result_promise.addErrback(lambda _: None) # ignore errors
178
+
179
+ req.notifyFinish().addErrback(lambda _: result_promise.cancel())
180
+
181
+ return NOT_DONE_YET
182
+
183
+ req.setResponseCode(FOUND)
184
+ req.setHeader(b"Location", "/transcriptions/%s" % (uid))
185
+ return b''
186
+
187
+ class LazyZipper(Insist):
188
+ def __init__(self, cachedir, transcriber, uid):
189
+ self.transcriber = transcriber
190
+ self.uid = uid
191
+ Insist.__init__(self, os.path.join(cachedir, '%s.zip' % (uid)))
192
+
193
+ def serialize_computation(self, outpath):
194
+ shutil.make_archive('.'.join(outpath.split('.')[:-1]), # We need to strip the ".zip" from the end
195
+ "zip", # ...because `shutil.make_archive` adds it back
196
+ os.path.join(self.transcriber.out_dir(self.uid)))
197
+
198
+ class TranscriptionZipper(Resource):
199
+ def __init__(self, cachedir, transcriber):
200
+ self.cachedir = cachedir
201
+ self.transcriber = transcriber
202
+ Resource.__init__(self)
203
+
204
+ def getChild(self, path, req):
205
+ uid = path.decode().split('.')[0]
206
+ t_dir = self.transcriber.out_dir(uid)
207
+ if os.path.exists(t_dir):
208
+ # TODO: Check that "status" is complete and only create a LazyZipper if so
209
+ # Otherwise, we could have incomplete transcriptions that get permanently zipped.
210
+ # For now, a solution will be hiding the button in the client until it's done.
211
+ lz = LazyZipper(self.cachedir, self.transcriber, uid)
212
+ if not isinstance(path, bytes):
213
+ path = path.encode()
214
+ self.putChild(path, lz)
215
+ return lz
216
+ else:
217
+ return Resource.getChild(self, path, req)
218
+
219
+ def serve(port=8765, interface='0.0.0.0', installSignalHandlers=0, nthreads=4, ntranscriptionthreads=2, data_dir=get_datadir('webdata')):
220
+ logging.info("SERVE %d, %s, %d", port, interface, installSignalHandlers)
221
+
222
+ if not os.path.exists(data_dir):
223
+ os.makedirs(data_dir)
224
+
225
+ zip_dir = os.path.join(data_dir, 'zip')
226
+ if not os.path.exists(zip_dir):
227
+ os.makedirs(zip_dir)
228
+
229
+ f = File(data_dir)
230
+
231
+ f.putChild(b'', File(get_resource('www/index.html')))
232
+ f.putChild(b'status.html', File(get_resource('www/status.html')))
233
+ f.putChild(b'preloader.gif', File(get_resource('www/preloader.gif')))
234
+
235
+ trans = Transcriber(data_dir, nthreads=nthreads, ntranscriptionthreads=ntranscriptionthreads)
236
+ trans_ctrl = TranscriptionsController(trans)
237
+ f.putChild(b'transcriptions', trans_ctrl)
238
+
239
+ trans_zippr = TranscriptionZipper(zip_dir, trans)
240
+ f.putChild(b'zip', trans_zippr)
241
+
242
+ s = Site(f)
243
+ logging.info("about to listen")
244
+ reactor.listenTCP(port, s, interface=interface)
245
+ logging.info("listening")
246
+
247
+ reactor.run(installSignalHandlers=installSignalHandlers)
248
+
249
+
250
+ if __name__=='__main__':
251
+ import argparse
252
+
253
+ parser = argparse.ArgumentParser(
254
+ description='Align a transcript to audio by generating a new language model.')
255
+ parser.add_argument('--host', default="0.0.0.0",
256
+ help='host to run http server on')
257
+ parser.add_argument('--port', default=8765, type=int,
258
+ help='port number to run http server on')
259
+ parser.add_argument('--nthreads', default=multiprocessing.cpu_count(), type=int,
260
+ help='number of alignment threads')
261
+ parser.add_argument('--ntranscriptionthreads', default=2, type=int,
262
+ help='number of full-transcription threads (memory intensive)')
263
+ parser.add_argument('--log', default="INFO",
264
+ help='the log level (DEBUG, INFO, WARNING, ERROR, or CRITICAL)')
265
+
266
+ args = parser.parse_args()
267
+
268
+ log_level = args.log.upper()
269
+ logging.getLogger().setLevel(log_level)
270
+
271
+ logging.info('gentle %s' % (gentle.__version__))
272
+ logging.info('listening at %s:%d\n' % (args.host, args.port))
273
+
274
+ serve(args.port, args.host, nthreads=args.nthreads, ntranscriptionthreads=args.ntranscriptionthreads, installSignalHandlers=1)
setup.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from setuptools import setup
2
+ from gentle import __version__
3
+
4
+ setup(
5
+ app=['serve.py'],
6
+ data_files=[],
7
+ options={'py2app': {
8
+ 'argv_emulation': False,
9
+ 'resources': 'k3,m3,ffmpeg,www,exp'
10
+ }},
11
+ name='gentle',
12
+ version=__version__,
13
+ description='Robust yet lenient forced-aligner built on Kaldi.',
14
+ url='http://lowerquality.com/gentle',
15
+ author='Robert M Ochshorn',
16
+ license='MIT',
17
+ packages=['gentle'],
18
+ install_requires=['twisted'],
19
+ test_suite='tests',
20
+ )