1#!/usr/bin/python
2
3# Audio Tools, a module and set of tools for manipulating audio data
4# Copyright (C) 2007-2014  Brian Langenberger
5
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
19
20import sys
21import os
22import os.path
23import audiotools
24import audiotools.cue
25import audiotools.ui
26import audiotools.text as _
27import termios
28from fractions import Fraction
29
30
31def split(progress, source_audiofile, destination_filename,
32          destination_class, compression, metadata,
33          pcm_frames_offset, total_pcm_frames):
34    try:
35        pcmreader = source_audiofile.to_pcm()
36
37        # if PCMReader has seek()
38        # use it to reduce the amount of frames to skip
39        if hasattr(pcmreader, "seek") and callable(pcmreader.seek):
40            pcm_frames_offset -= pcmreader.seek(pcm_frames_offset)
41
42        destination_audiofile = destination_class.from_pcm(
43            str(destination_filename),
44            audiotools.PCMReaderProgress(
45                audiotools.PCMReaderWindow(pcmreader,
46                                           pcm_frames_offset,
47                                           total_pcm_frames),
48                total_pcm_frames,
49                progress),
50            compression,
51            total_pcm_frames)
52
53        if metadata is not None:
54            destination_audiofile.set_metadata(metadata)
55    except KeyboardInterrupt:
56        # remove partially split file, if any
57        try:
58            os.unlink(str(destination_filename))
59        except OSError:
60            pass
61
62    return str(destination_filename)
63
64
65def split_raw(progress, source_filename,
66              sample_rate, channels, channel_mask, bits_per_sample,
67              destination_filename, destination_class, compression,
68              metadata, pcm_frames_offset, total_pcm_frames):
69    f = open(source_filename, "rb")
70    try:
71        # skip initial offset
72        f.seek(pcm_frames_offset * channels * (bits_per_sample // 8))
73
74        destination_audiofile = destination_class.from_pcm(
75            str(destination_filename),
76            audiotools.PCMReaderProgress(
77                audiotools.PCMReaderHead(
78                    audiotools.PCMFileReader(file=f,
79                                             sample_rate=sample_rate,
80                                             channels=channels,
81                                             channel_mask=channel_mask,
82                                             bits_per_sample=bits_per_sample),
83                    total_pcm_frames),
84                total_pcm_frames,
85                progress),
86            compression,
87            total_pcm_frames)
88
89        if metadata is not None:
90            destination_audiofile.set_metadata(metadata)
91
92        return str(destination_filename)
93    except KeyboardInterrupt:
94        # remove partially split file, if any
95        try:
96            os.unlink(str(destination_filename))
97        except OSError:
98            pass
99    finally:
100        f.close()
101
102
103if (__name__ == '__main__'):
104    import argparse
105
106    parser = argparse.ArgumentParser(description=_.DESCRIPTION_TRACKSPLIT)
107
108    parser.add_argument("--version",
109                        action="version",
110                        version="Python Audio Tools %s" % (audiotools.VERSION))
111
112    parser.add_argument("-I", "--interactive",
113                        action="store_true",
114                        default=False,
115                        dest="interactive",
116                        help=_.OPT_INTERACTIVE_OPTIONS)
117
118    parser.add_argument("--cue",
119                        dest="cuesheet",
120                        metavar="FILENAME",
121                        help=_.OPT_CUESHEET_TRACKSPLIT)
122
123    parser.add_argument("-V", "--verbose",
124                        dest="verbosity",
125                        choices=audiotools.VERBOSITY_LEVELS,
126                        default=audiotools.DEFAULT_VERBOSITY,
127                        help=_.OPT_VERBOSE)
128
129    conversion = parser.add_argument_group(_.OPT_CAT_ENCODING)
130
131    conversion.add_argument("-t", "--type",
132                            dest="type",
133                            choices=list(sorted(audiotools.TYPE_MAP.keys())) +
134                            ["help"],
135                            help=_.OPT_TYPE)
136
137    conversion.add_argument("-q", "--quality",
138                            dest="quality",
139                            help=_.OPT_QUALITY)
140
141    conversion.add_argument("-d", "--dir",
142                            dest="dir",
143                            default=".",
144                            help=_.OPT_DIR)
145
146    conversion.add_argument("--format",
147                            default=None,
148                            dest="format",
149                            help=_.OPT_FORMAT)
150
151    conversion.add_argument("-j", "--joint",
152                            type=int,
153                            default=audiotools.MAX_JOBS,
154                            dest="max_processes",
155                            help=_.OPT_JOINT)
156
157    lookup = parser.add_argument_group(_.OPT_CAT_CD_LOOKUP)
158
159    lookup.add_argument("--musicbrainz-server",
160                        dest="musicbrainz_server",
161                        default=audiotools.MUSICBRAINZ_SERVER,
162                        metavar="HOSTNAME")
163
164    lookup.add_argument("--musicbrainz-port",
165                        type=int,
166                        dest="musicbrainz_port",
167                        default=audiotools.MUSICBRAINZ_PORT,
168                        metavar="PORT")
169
170    lookup.add_argument("--no-musicbrainz",
171                        action="store_false",
172                        dest="use_musicbrainz",
173                        default=audiotools.MUSICBRAINZ_SERVICE,
174                        help=_.OPT_NO_MUSICBRAINZ)
175
176    lookup.add_argument("--freedb-server",
177                        dest="freedb_server",
178                        default=audiotools.FREEDB_SERVER,
179                        metavar="HOSTNAME")
180
181    lookup.add_argument("--freedb-port",
182                        type=int,
183                        dest="freedb_port",
184                        default=audiotools.FREEDB_PORT,
185                        metavar="PORT")
186
187    lookup.add_argument("--no-freedb",
188                        action="store_false",
189                        dest="use_freedb",
190                        default=audiotools.FREEDB_SERVICE,
191                        help=_.OPT_NO_FREEDB)
192
193    lookup.add_argument("-D", "--default",
194                        dest="use_default",
195                        action="store_true",
196                        default=False,
197                        help=_.OPT_DEFAULT)
198
199    metadata = parser.add_argument_group(_.OPT_CAT_METADATA)
200
201    metadata.add_argument("--album-number",
202                          dest="album_number",
203                          type=int,
204                          default=0,
205                          help=_.OPT_ALBUM_NUMBER)
206
207    metadata.add_argument("--album-total",
208                          dest="album_total",
209                          type=int,
210                          default=0,
211                          help=_.OPT_ALBUM_TOTAL)
212
213    metadata.add_argument("--replay-gain",
214                          action="store_true",
215                          default=None,
216                          dest="add_replay_gain",
217                          help=_.OPT_REPLAY_GAIN)
218
219    metadata.add_argument("--no-replay-gain",
220                          action="store_false",
221                          default=None,
222                          dest="add_replay_gain",
223                          help=_.OPT_NO_REPLAY_GAIN)
224
225    parser.add_argument("filename",
226                        metavar="FILENAME",
227                        help=_.OPT_INPUT_FILENAME)
228
229    options = parser.parse_args()
230
231    msg = audiotools.Messenger(options.verbosity == "quiet")
232
233    # ensure interactive mode is available, if selected
234    if options.interactive and (not audiotools.ui.AVAILABLE):
235        audiotools.ui.not_available_message(msg)
236        sys.exit(1)
237
238    # get the AudioFile class we are converted to
239    if options.type == 'help':
240        audiotools.ui.show_available_formats(msg)
241        sys.exit(0)
242    if options.type is not None:
243        AudioType = audiotools.TYPE_MAP[options.type]
244    else:
245        AudioType = audiotools.TYPE_MAP[audiotools.DEFAULT_TYPE]
246
247    # ensure the selected compression is compatible with that class
248    if options.quality == 'help':
249        audiotools.ui.show_available_qualities(msg, AudioType)
250        sys.exit(0)
251    elif options.quality is None:
252        options.quality = audiotools.__default_quality__(AudioType.NAME)
253    elif options.quality not in AudioType.COMPRESSION_MODES:
254        msg.error(_.ERR_UNSUPPORTED_COMPRESSION_MODE %
255                  {"quality": options.quality,
256                   "type": AudioType.NAME})
257        sys.exit(1)
258
259    audiofiles = audiotools.open_files([options.filename],
260                                       sorted=False,
261                                       messenger=msg)
262    if len(audiofiles) != 1:
263        msg.error(_.ERR_1_FILE_REQUIRED)
264        sys.exit(1)
265    else:
266        audiofile = audiofiles[0]
267        input_filename = audiotools.Filename(audiofile.filename)
268        input_filenames = {input_filename}
269
270    base_directory = options.dir
271    encoded_filenames = []
272
273    if options.cuesheet is not None:
274        # grab the cuesheet we're using to split tracks
275        # (this overrides an embedded cuesheet)
276        try:
277            cuesheet = audiotools.read_sheet(options.cuesheet)
278            input_filenames.add(audiotools.Filename(options.cuesheet))
279        except audiotools.SheetException as err:
280            msg.error(err)
281            sys.exit(1)
282    else:
283        cuesheet = audiofile.get_cuesheet()
284        if cuesheet is None:
285            msg.error(_.ERR_TRACKSPLIT_NO_CUESHEET)
286            sys.exit(1)
287
288    if not cuesheet.image_formatted():
289        msg.error(_.ERR_CUE_INVALID_FORMAT)
290        sys.exit(1)
291
292    cuesheet_offsets = [cuesheet.track_offset(t) for t in
293                        cuesheet.track_numbers()]
294
295    cuesheet_lengths = [cuesheet.track_length(t) for t in
296                        cuesheet.track_numbers()]
297
298    audiofile_size = audiofile.seconds_length()
299
300    if cuesheet_lengths[-1] is None:
301        # last track length unknown, so ensure there's enough room
302        # based on the total size of the source
303        last_track_size = (audiofile_size -
304                           sum(cuesheet_lengths[0:-1]) -
305                           cuesheet.pre_gap())
306        if last_track_size >= 0:
307            cuesheet_lengths.pop()
308            cuesheet_lengths.append(last_track_size)
309        else:
310            msg.error(_.ERR_TRACKSPLIT_OVERLONG_CUESHEET)
311            sys.exit(1)
312    else:
313        # last track length is known, so ensure the size
314        # of all lengths and the pre-gap is the same size as the source
315        if (cuesheet.pre_gap() + sum(cuesheet_lengths)) != audiofile_size:
316            msg.error(_.ERR_TRACKSPLIT_OVERLONG_CUESHEET)
317            sys.exit(1)
318
319    output_track_count = len(cuesheet)
320
321    # use cuesheet to query metadata services for metadata choices
322    try:
323        metadata_choices = audiotools.sheet_metadata_lookup(
324            sheet=cuesheet,
325            total_pcm_frames=audiofile.total_frames(),
326            sample_rate=audiofile.sample_rate(),
327            musicbrainz_server=options.musicbrainz_server,
328            musicbrainz_port=options.musicbrainz_port,
329            freedb_server=options.freedb_server,
330            freedb_port=options.freedb_port,
331            use_musicbrainz=options.use_musicbrainz,
332            use_freedb=options.use_freedb)
333    except KeyboardInterrupt:
334        msg.ansi_clearline()
335        msg.error(_.ERR_CANCELLED)
336        sys.exit(1)
337
338    # populate any empty Album-level metadata fields
339    # with those from original file, if any
340    album_metadata = audiofile.get_metadata()
341    if album_metadata is not None:
342        album_fields = {attr: field for (attr, field) in
343                        album_metadata.filled_fields()
344                        if (attr in {"album_name",
345                                     "artist_name",
346                                     "performer_name",
347                                     "composer_name",
348                                     "conductor_name",
349                                     "media",
350                                     "catalog",
351                                     "copyright",
352                                     "publisher",
353                                     "year",
354                                     "date",
355                                     "album_number",
356                                     "album_total",
357                                     "comment"})}
358
359        for c in metadata_choices:
360            for m in c:
361                for (attr, field) in m.empty_fields():
362                    if attr in album_fields:
363                        setattr(m, attr, album_fields[attr])
364
365    # populate any remaining metadata fields
366    # with those from cuesheet, if any
367    cuesheet_metadata = cuesheet.get_metadata()
368    for c in metadata_choices:
369        for (track_metadata, sheet_track) in zip(c, cuesheet):
370            sheet_track_metadata = sheet_track.get_metadata()
371            for (attr, field) in track_metadata.empty_fields():
372                if ((sheet_track_metadata is not None) and
373                    (getattr(sheet_track_metadata, attr) is not None)):
374                    setattr(track_metadata,
375                            attr,
376                            getattr(sheet_track_metadata, attr))
377                elif ((cuesheet_metadata is not None) and
378                      (getattr(cuesheet_metadata, attr) is not None)):
379                    setattr(track_metadata,
380                            attr,
381                            getattr(cuesheet_metadata, attr))
382
383    # update MetaData with command-line album-number/total, if given
384    if options.album_number != 0:
385        for c in metadata_choices:
386            for m in c:
387                m.album_number = options.album_number
388
389    if options.album_total != 0:
390        for c in metadata_choices:
391            for m in c:
392                m.album_total = options.album_total
393
394    # decide which metadata and output options to use when splitting tracks
395    if options.interactive:
396        # pick choice using interactive widget
397        output_widget = audiotools.ui.OutputFiller(
398            track_labels=[_.LAB_TRACK_X_OF_Y % (i + 1, output_track_count)
399                          for i in range(output_track_count)],
400            metadata_choices=metadata_choices,
401            input_filenames=[input_filename for i in
402                             range(output_track_count)],
403            output_directory=options.dir,
404            format_string=(options.format if
405                           (options.format is not None) else
406                           audiotools.FILENAME_FORMAT),
407            output_class=AudioType,
408            quality=options.quality,
409            completion_label=_.LAB_TRACKSPLIT_APPLY)
410
411        loop = audiotools.ui.urwid.MainLoop(
412            output_widget,
413            audiotools.ui.style(),
414            unhandled_input=output_widget.handle_text,
415            pop_ups=True)
416        try:
417            loop.run()
418            msg.ansi_clearscreen()
419        except (termios.error, IOError):
420            msg.error(_.ERR_TERMIOS_ERROR)
421            msg.info(_.ERR_TERMIOS_SUGGESTION)
422            msg.info(audiotools.ui.xargs_suggestion(sys.argv))
423            sys.exit(1)
424
425        if not output_widget.cancelled():
426            output_tracks = list(output_widget.output_tracks())
427        else:
428            sys.exit(0)
429    else:
430        # pick choice without using GUI
431        try:
432            output_tracks = list(
433                audiotools.ui.process_output_options(
434                    metadata_choices=metadata_choices,
435                    input_filenames=[input_filename for i in
436                                     range(output_track_count)],
437                    output_directory=options.dir,
438                    format_string=options.format,
439                    output_class=AudioType,
440                    quality=options.quality,
441                    msg=msg,
442                    use_default=options.use_default))
443        except audiotools.UnsupportedTracknameField as err:
444            err.error_msg(msg)
445            sys.exit(1)
446        except (audiotools.InvalidFilenameFormat,
447                audiotools.OutputFileIsInput,
448                audiotools.DuplicateOutputFile) as err:
449            msg.error(err)
450            sys.exit(1)
451
452    # perform actual track splitting and tagging
453    jobs = zip(cuesheet_offsets, cuesheet_lengths, output_tracks)
454
455    queue = audiotools.ExecProgressQueue(msg)
456
457    if audiofile.seekable():
458        for (offset, length, (output_class,
459                              output_filename,
460                              output_quality,
461                              output_metadata)) in jobs:
462            try:
463                audiotools.make_dirs(str(output_filename))
464            except OSError as err:
465                msg.os_error(err)
466                sys.exit(1)
467
468            queue.execute(
469                function=split,
470                progress_text=output_filename.__unicode__(),
471                completion_output=_.LAB_ENCODE % {
472                    "source": audiotools.Filename(audiofile.filename),
473                    "destination": output_filename},
474                source_audiofile=audiofile,
475                destination_filename=output_filename,
476                destination_class=output_class,
477                compression=output_quality,
478                metadata=output_metadata,
479                pcm_frames_offset=int(offset * audiofile.sample_rate()),
480                total_pcm_frames=int(length * audiofile.sample_rate()))
481
482        try:
483            encoded_tracks = audiotools.open_files(
484                queue.run(options.max_processes))
485        except audiotools.EncodingError as err:
486            msg.error(err)
487            sys.exit(1)
488        except KeyboardInterrupt:
489            msg.error(_.ERR_CANCELLED)
490            sys.exit(1)
491    else:
492        import tempfile
493
494        # if input file isn't seekable
495
496        # decode it into a single PCM blob of binary data
497        temp_blob = tempfile.NamedTemporaryFile()
498        cache_progress = audiotools.SingleProgressDisplay(
499            msg, _.LAB_CACHING_FILE)
500        try:
501            audiotools.transfer_framelist_data(
502                audiotools.PCMReaderProgress(
503                    audiofile.to_pcm(),
504                    audiofile.total_frames(),
505                    cache_progress.update),
506                temp_blob.write)
507        except audiotools.DecodingError as err:
508            cache_progress.clear_rows()
509            msg.error(err)
510            temp_blob.close()
511            sys.exit(1)
512        except KeyboardInterrupt:
513            cache_progress.clear_rows()
514            msg.error(_.ERR_CANCELLED)
515            temp_blob.close()
516            sys.exit(1)
517
518        cache_progress.clear_rows()
519        temp_blob.flush()
520
521        # split the blob using multiple jobs
522        for (offset, length, (output_class,
523                              output_filename,
524                              output_quality,
525                              output_metadata)) in jobs:
526            try:
527                audiotools.make_dirs(str(output_filename))
528            except OSError as err:
529                msg.os_error(err)
530                temp_blob.close()
531                sys.exit(1)
532
533            queue.execute(
534                function=split_raw,
535                progress_text=output_filename.__unicode__(),
536                completion_output=_.LAB_ENCODE % {
537                    "source": audiotools.Filename(audiofile.filename),
538                    "destination": output_filename},
539                source_filename=temp_blob.name,
540                sample_rate=audiofile.sample_rate(),
541                channels=audiofile.channels(),
542                channel_mask=int(audiofile.channel_mask()),
543                bits_per_sample=audiofile.bits_per_sample(),
544                destination_filename=output_filename,
545                destination_class=output_class,
546                compression=output_quality,
547                metadata=output_metadata,
548                pcm_frames_offset=int(offset * audiofile.sample_rate()),
549                total_pcm_frames=int(length * audiofile.sample_rate()))
550
551        try:
552            encoded_tracks = audiotools.open_files(
553                queue.run(options.max_processes))
554        except audiotools.EncodingError as err:
555            msg.error(err)
556            temp_blob.close()
557            sys.exit(1)
558        except KeyboardInterrupt:
559            msg.error(_.ERR_CANCELLED)
560            temp_blob.close()
561            sys.exit(1)
562
563        # then delete the blob when finished
564        temp_blob.close()
565
566    # apply ReplayGain to split tracks, if requested
567    if (output_class.supports_replay_gain() and
568        (options.add_replay_gain if options.add_replay_gain is not None else
569         audiotools.ADD_REPLAYGAIN)):
570        rg_progress = audiotools.ReplayGainProgressDisplay(msg)
571        rg_progress.initial_message()
572        try:
573            # separate encoded files by album_name and album_number
574            for album in audiotools.group_tracks(encoded_tracks):
575                # add ReplayGain to groups of files
576                # belonging to the same album
577
578                audiotools.add_replay_gain(album, rg_progress.update)
579        except ValueError as err:
580            rg_progress.clear_rows()
581            msg.error(err)
582            sys.exit(1)
583        except KeyboardInterrupt:
584            rg_progress.clear_rows()
585            msg.error(_.ERR_CANCELLED)
586            sys.exit(1)
587        rg_progress.final_message()
588