Browse Source

Add support for audio file upload

pull/495/head
Clemens Wolff 5 years ago
parent
commit
91d969d951
8 changed files with 73 additions and 17 deletions
  1. 23
      app/api/tests/test_utils.py
  2. 19
      app/api/utils.py
  3. 4
      app/api/views.py
  4. 4
      app/server/static/components/annotation.pug
  5. 26
      app/server/static/components/annotationMixin.js
  6. 2
      app/server/static/components/examples/upload_speech2text.jsonl
  7. 10
      app/server/static/components/upload_speech2text.vue
  8. 2
      app/server/templates/dataset.html

23
app/api/tests/test_utils.py

@ -4,9 +4,10 @@ from django.test import TestCase
from seqeval.metrics.sequence_labeling import get_entities
from ..exceptions import FileParseException
from ..models import Label, Document
from ..utils import BaseStorage, ClassificationStorage, SequenceLabelingStorage, Seq2seqStorage, CoNLLParser
from ..utils import iterable_to_io
from ..utils import AudioParser, iterable_to_io
class TestBaseStorage(TestCase):
@ -138,6 +139,26 @@ class TestCoNLLParser(TestCase):
})
class TestAudioParser(TestCase):
def test_parse_mp3(self):
f = io.BytesIO(b'...')
f.name = 'test.mp3'
actual = next(AudioParser().parse(f))
self.assertEqual(actual, [{
'audio': 'data:audio/mpeg;base64,Li4u',
'meta': '{"filename": "test.mp3"}',
}])
def test_parse_unknown(self):
f = io.BytesIO(b'...')
f.name = 'unknown.unknown'
with self.assertRaises(FileParseException):
next(AudioParser().parse(f))
class TestIterableToIO(TestCase):
def test(self):
def iterable():

19
app/api/utils.py

@ -1,7 +1,9 @@
import base64
import csv
import io
import itertools
import json
import mimetypes
import re
from collections import defaultdict
@ -227,7 +229,9 @@ class Speech2textStorage(BaseStorage):
@transaction.atomic
def save(self, user):
for data in self.data:
doc = self.save_doc([{'text': audio['audio']} for audio in data])
for audio in data:
audio['text'] = audio.pop('audio')
doc = self.save_doc(data)
annotations = self.make_annotations(doc, data)
self.save_annotation(annotations, user)
@ -411,6 +415,19 @@ class JSONParser(FileParser):
yield data
class AudioParser(FileParser):
def parse(self, file):
file_type, _ = mimetypes.guess_type(file.name, strict=False)
if not file_type:
raise FileParseException(line_num=1, line='Unable to guess file type')
audio = base64.b64encode(file.read())
yield [{
'audio': f'data:{file_type};base64,{audio.decode("ascii")}',
'meta': json.dumps({'filename': file.name}),
}]
class JSONLRenderer(JSONRenderer):
def render(self, data, accepted_media_type=None, renderer_context=None):

4
app/api/views.py

@ -18,7 +18,7 @@ from .models import Project, Label, Document, RoleMapping, Role
from .permissions import IsProjectAdmin, IsAnnotatorAndReadOnly, IsAnnotator, IsAnnotationApproverAndReadOnly, IsOwnAnnotation, IsAnnotationApprover
from .serializers import ProjectSerializer, LabelSerializer, DocumentSerializer, UserSerializer
from .serializers import ProjectPolymorphicSerializer, RoleMappingSerializer, RoleSerializer
from .utils import CSVParser, ExcelParser, JSONParser, PlainTextParser, CoNLLParser, iterable_to_io
from .utils import CSVParser, ExcelParser, JSONParser, PlainTextParser, CoNLLParser, AudioParser, iterable_to_io
from .utils import JSONLRenderer
from .utils import JSONPainter, CSVPainter
@ -243,6 +243,8 @@ class TextUploadAPI(APIView):
return CoNLLParser()
elif file_format == 'excel':
return ExcelParser()
elif file_format == 'audio':
return AudioParser()
else:
raise ValidationError('format {} is invalid.'.format(file_format))

4
app/server/static/components/annotation.pug

@ -74,7 +74,9 @@ div.columns(v-cloak="")
)
span.icon
i.fa.fa-check(v-show="annotations[index] && annotations[index].length")
span.name {{ doc.text.slice(0, 60) }}...
span.name
span(v-if="documentMetadataFor(index) && documentMetadataFor(index).filename") {{ documentMetadataFor(index).filename }}
span(v-else) {{ doc.text.slice(0, 60) }}...
div.column.is-7.is-offset-1.message.hero.is-fullheight#message-pane

26
app/server/static/components/annotationMixin.js

@ -177,6 +177,20 @@ export default {
});
},
documentMetadataFor(i) {
const document = this.docs[i];
if (document == null || document.meta == null) {
return null;
}
const metadata = JSON.parse(document.meta);
if (isEmpty(metadata)) {
return null;
}
return metadata;
},
getState() {
if (this.picked === 'all') {
return '';
@ -292,17 +306,7 @@ export default {
},
documentMetadata() {
const document = this.docs[this.pageNumber];
if (document == null || document.meta == null) {
return null;
}
const metadata = JSON.parse(document.meta);
if (isEmpty(metadata)) {
return null;
}
return metadata;
return this.documentMetadataFor(this.pageNumber);
},
id2label() {

2
app/server/static/components/examples/upload_speech2text.jsonl

@ -1,3 +1,3 @@
{"audio": "data:audio/mpeg;base64,..."}
{"audio": "data:audio/mpeg;base64,...", "meta":{"filename": "a-story.mp3"}}
{"audio": "https://server.com/audio.ogg"}
{"audio": "data:audio/wav;base64,...", "transcription": "Once upon a time..."}

10
app/server/static/components/upload_speech2text.vue

@ -12,6 +12,16 @@ block select-format-area
)
| JSONL
label.radio
input(
type="radio"
name="format"
value="audio"
v-bind:checked="format === 'audio'"
v-model="format"
)
| Audio
block example-format-area
pre.code-block(v-show="format === 'json'")
code.json

2
app/server/templates/dataset.html

@ -24,7 +24,7 @@
{% for doc in object_list %}
<tr>
<td>{{ forloop.counter0|add:page_obj.start_index }}</td>
<td>{{ doc.text|truncatechars:200 }}</td>
<td>{{ doc.text|truncatechars:100 }}</td>
<td>
<p class="control">
<button class="button is-text delete-document-button" data-delete-document-id="{{ doc.id }}">

Loading…
Cancel
Save