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
20
21import os
22import os.path
23import sys
24import tempfile
25import subprocess
26import audiotools
27import audiotools.toc
28import audiotools.text as _
29from audiotools.decoders import SameSample
30
31MAX_CPUS = audiotools.MAX_JOBS
32
33
34def convert_to_wave(progress, audiofile, wave_filename):
35    try:
36        if (((audiofile.sample_rate() == 44100) and
37             (audiofile.channels() == 2) and
38             (audiofile.bits_per_sample() == 16))):  # already CD quality
39            audiofile.convert(target_path=wave_filename,
40                              target_class=audiotools.WaveAudio,
41                              progress=progress)
42
43        else:                                        # convert to CD quality
44            pcm = audiotools.PCMReaderProgress(
45                pcmreader=audiotools.PCMConverter(
46                    audiofile.to_pcm(),
47                    sample_rate=44100,
48                    channels=2,
49                    channel_mask=audiotools.ChannelMask.from_channels(2),
50                    bits_per_sample=16),
51                total_frames=(audiofile.total_frames() *
52                              44100 // audiofile.sample_rate()),
53                progress=progress)
54            audiotools.WaveAudio.from_pcm(wave_filename, pcm)
55            pcm.close()
56    except audiotools.EncodingError as err:
57        pass
58    except KeyboardInterrupt:
59        pass
60
61
62def CD_reader_size(audiofile):
63    """returns a (PCMReader, total_pcm_frames) of track in CD format"""
64
65    if (((audiofile.bits_per_sample() == 16) and
66         (audiofile.channels() == 2) and
67         (audiofile.sample_rate() == 44100) and
68         audiofile.lossless())):
69        return (audiofile.to_pcm(), audiofile.total_frames())
70    else:
71        # if input audio isn't CD quality or lossless,
72        # pull audio data into RAM so PCM frame size
73        # can be calculated exactly
74        pcm_frames = []
75        audiotools.transfer_data(
76            audiotools.PCMConverter(audiofile.to_pcm(),
77                                    44100, 2, 0x3, 16).read,
78            pcm_frames.append)
79        return (PCMBuffer(pcm_frames, 44100, 2, 0x3, 16),
80                sum(f.frames for f in pcm_frames))
81
82
83class PCMBuffer(object):
84    def __init__(self, pcm_frames,
85                 sample_rate, channels, channel_mask, bits_per_sample):
86        from collections import deque
87
88        self.pcm_frames = deque(pcm_frames)
89        self.sample_rate = sample_rate
90        self.channels = channels
91        self.channel_mask = channel_mask
92        self.bits_per_sample = bits_per_sample
93
94    def read(self, frames):
95        if self.pcm_frames is not None:
96            try:
97                return self.pcm_frames.popleft()
98            except IndexError:
99                from audiotools.pcm import empty_framelist
100                return empty_framelist(self.channels, self.bits_per_sample)
101        else:
102            raise ValueError()
103
104    def close(self):
105        self.pcm_frames = None
106
107
108if (__name__ == '__main__'):
109    import argparse
110
111    parser = argparse.ArgumentParser(description=_.DESCRIPTION_TRACK2CD)
112
113    parser.add_argument("--version",
114                        action="version",
115                        version="Python Audio Tools %s" % (audiotools.VERSION))
116
117    parser.add_argument("-V", "--verbose",
118                        dest="verbosity",
119                        choices=audiotools.VERBOSITY_LEVELS,
120                        default=audiotools.DEFAULT_VERBOSITY,
121                        help=_.OPT_VERBOSE)
122
123    parser.add_argument("-c", "--cdrom",
124                        dest="dev",
125                        default=audiotools.DEFAULT_CDROM)
126
127    parser.add_argument("-s", "--speed",
128                        dest="speed",
129                        default=20,
130                        type=int,
131                        help=_.OPT_SPEED)
132
133    parser.add_argument("--cue",
134                        dest="cuesheet",
135                        metavar="FILENAME",
136                        help=_.OPT_CUESHEET_TRACK2CD)
137
138    parser.add_argument("-j", "--joint",
139                        type=int,
140                        default=MAX_CPUS,
141                        dest="max_processes",
142                        help=_.OPT_JOINT)
143
144    parser.add_argument("filenames",
145                        metavar="FILENAME",
146                        nargs="+",
147                        help=_.OPT_INPUT_FILENAME)
148
149    options = parser.parse_args()
150
151    msg = audiotools.Messenger(options.verbosity == "quiet")
152
153    if options.max_processes < 1:
154        msg.error(_.ERR_INVALID_JOINT)
155        sys.exit(1)
156    else:
157        max_processes = options.max_processes
158
159    write_offset = audiotools.config.getint_default("System",
160                                                    "cdrom_write_offset",
161                                                    0)
162
163    audiofiles = audiotools.open_files(options.filenames,
164                                       sorted=False,
165                                       messenger=msg,
166                                       warn_duplicates=True)
167
168    if len(audiofiles) < 1:
169        msg.error(_.ERR_FILES_REQUIRED)
170        sys.exit(1)
171
172    if (((len(audiofiles) == 1) and
173         (audiofiles[0].get_cuesheet() is not None))):
174        # writing a single file with an embedded cuesheet
175        # so extract its contents to wave/cue and call cdrdao
176
177        if not audiotools.BIN.can_execute(audiotools.BIN['cdrdao']):
178            msg.error(_.ERR_NO_CDRDAO)
179            msg.info(_.ERR_GET_CDRDAO)
180            sys.exit(1)
181
182        cuesheet = audiofiles[0].get_cuesheet()
183
184        temptoc = tempfile.NamedTemporaryFile(suffix='.toc')
185        tempwav = tempfile.NamedTemporaryFile(suffix='.wav')
186
187        audiotools.toc.write_tocfile(
188            cuesheet,
189            audiotools.Filename(tempwav.name).__unicode__(),
190            temptoc)
191        temptoc.flush()
192
193        progress = audiotools.SingleProgressDisplay(
194            msg, _.LAB_CONVERTING_FILE)
195
196        try:
197            if (((audiofiles[0].sample_rate() == 44100) and
198                 (audiofiles[0].channels() == 2) and
199                 (audiofiles[0].bits_per_sample() == 16))):
200                # already CD quality, so no additional conversion necessary
201                temptrack = audiotools.WaveAudio.from_pcm(
202                    tempwav.name,
203                    audiotools.PCMReaderProgress(
204                        pcmreader=(audiofiles[0].to_pcm() if
205                                   (write_offset == 0) else
206                                   audiotools.PCMReaderWindow(
207                                       audiofiles[0].to_pcm(),
208                                       write_offset,
209                                       audiofiles[0].total_frames())),
210                        total_frames=audiofiles[0].total_frames(),
211                        progress=progress.update))
212
213            else:
214                # not CD quality, so convert and adjust total frames as needed
215                temptrack = audiotools.WaveAudio.from_pcm(
216                    tempwav.name,
217                    audiotools.PCMReaderProgress(
218                        pcmreader=audiotools.PCMConverter(
219                            pcmreader=(audiofiles[0].to_pcm() if
220                                       (write_offset == 0) else
221                                       audiotools.PCMReaderWindow(
222                                           audiofiles[0].to_pcm(),
223                                           write_offset,
224                                           audiofiles[0].total_frames())),
225                            sample_rate=44100,
226                            channels=2,
227                            channel_mask=0x3,
228                            bits_per_sample=16),
229                        total_frames=(audiofiles[0].total_frames() *
230                                      44100 //
231                                      audiofiles[0].sample_rate()),
232                        progress=progress.update))
233        except KeyboardInterrupt:
234            progress.clear_rows()
235            tempwav.close()
236            msg.error(_.ERR_CANCELLED)
237            sys.exit(1)
238
239        progress.clear_rows()
240
241        os.chdir(os.path.dirname(tempwav.name))
242        cdrdao_args = [audiotools.BIN["cdrdao"], "write"]
243
244        cdrdao_args.append("--device")
245        cdrdao_args.append(options.dev)
246
247        cdrdao_args.append("--speed")
248        cdrdao_args.append(str(options.speed))
249
250        cdrdao_args.append(temptoc.name)
251
252        if options.verbosity != 'quiet':
253            subprocess.call(cdrdao_args)
254        else:
255            devnull = open(os.devnull, 'wb')
256            sub = subprocess.Popen(cdrdao_args,
257                                   stdout=devnull,
258                                   stderr=devnull)
259            sub.wait()
260            devnull.close()
261
262        temptoc.close()
263        tempwav.close()
264
265    elif options.cuesheet is not None:
266        # writing tracks with a cuesheet,
267        # so combine them into a single wave/cue and call cdrdao
268
269        if not audiotools.BIN.can_execute(audiotools.BIN['cdrdao']):
270            msg.error(_.ERR_NO_CDRDAO)
271            msg.info(_.ERR_GET_CDRDAO)
272            sys.exit(1)
273
274        if len({f.sample_rate() for f in audiofiles}) != 1:
275            msg.error(_.ERR_SAMPLE_RATE_MISMATCH)
276            sys.exit(1)
277
278        if len({f.channels() for f in audiofiles}) != 1:
279            msg.error(_.ERR_CHANNEL_COUNT_MISMATCH)
280            sys.exit(1)
281
282        if len({f.bits_per_sample() for f in audiofiles}) != 1:
283            msg.error(_.ERR_BPS_MISMATCH)
284            sys.exit(1)
285
286        try:
287            cuesheet = audiotools.read_sheet(options.cuesheet)
288        except audiotools.SheetException as err:
289            msg.error(err)
290            sys.exit(1)
291
292        # ensure sheet is formatted for CD images
293        if not cuesheet.image_formatted():
294            msg.error(_.ERR_CUE_INVALID_FORMAT)
295            sys.exit(1)
296
297        # ensure sheet fits the tracks it's given
298        if len(cuesheet) != len(audiofiles):
299            msg.error(_.ERR_CUE_INSUFFICIENT_TRACKS)
300            sys.exit(1)
301
302        for (input_track, cuesheet_track) in zip(audiofiles,
303                                                 cuesheet.track_numbers()):
304            track_length = cuesheet.track_length(cuesheet_track)
305            if ((track_length is not None) and
306                (track_length != input_track.seconds_length())):
307                msg.error(_.ERR_CUE_LENGTH_MISMATCH % (cuesheet_track))
308                sys.exit(1)
309
310        tempcue = tempfile.NamedTemporaryFile(suffix='.cue')
311        tempwav = tempfile.NamedTemporaryFile(suffix='.wav')
312
313        audiotools.toc.write_tocfile(
314            cuesheet,
315            audiotools.Filename(tempwav.name).__unicode__(),
316            tempcue)
317        tempcue.flush()
318
319        input_frames = (int(audiofiles[0].sample_rate() * cuesheet.pre_gap()) +
320                        sum([af.total_frames() for af in audiofiles]))
321
322        if (((audiofiles[0].sample_rate() == 44100) and
323             (audiofiles[0].channels() == 2) and
324             (audiofiles[0].bits_per_sample() == 16))):
325            pcmreader = audiotools.PCMCat(
326                [SameSample(
327                    sample=0,
328                    total_pcm_frames=int(44100 * cuesheet.pre_gap()),
329                    sample_rate=44100,
330                    channels=2,
331                    channel_mask=0x3,
332                    bits_per_sample=16)] +
333                [af.to_pcm() for af in audiofiles])
334            output_frames = input_frames
335        else:
336            # this presumes a cuesheet and non-CD audio
337            # though theoretically possible, it's difficult to
338            # envision a case in which this happens
339            pcmreader = audiotools.PCMConverter(
340                pcmreader=audiotools.PCMCat(
341                    [SameSample(
342                        sample=0,
343                        total_pcm_frames=int(audiofiles[0].sample_rate() *
344                                             cuesheet.pre_gap()),
345                        channels=audiofiles[0].channels(),
346                        channel_mask=int(audiofiles[0].channel_mask()),
347                        bits_per_sample=audiofiles[0].bits_per_sample())] +
348                    [af.to_pcm() for af in audiofiles]),
349                sample_rate=44100,
350                channels=2,
351                channel_mask=0x3,
352                bits_per_sample=16)
353            output_frames = (input_frames * 44100 //
354                             audiofiles[0].sample_rate())
355
356        progress = audiotools.SingleProgressDisplay(
357            msg, _.LAB_CONVERTING_FILE)
358
359        try:
360            if write_offset == 0:
361                temptrack = audiotools.WaveAudio.from_pcm(
362                    tempwav.name,
363                    audiotools.PCMReaderProgress(
364                        pcmreader=pcmreader,
365                        total_frames=output_frames,
366                        progress=progress.update))
367            else:
368                temptrack = audiotools.WaveAudio.from_pcm(
369                    tempwav.name,
370                    audiotools.PCMReaderProgress(
371                        pcmreader=audiotools.PCMReaderWindow(pcmreader,
372                                                             write_offset,
373                                                             input_frames),
374                        total_frames=output_frames,
375                        progress=progress.update))
376        except KeyboardInterrupt:
377            progress.clear_rows()
378            tempwav.close()
379            msg.error(_.ERR_CANCELLED)
380            sys.exit(1)
381
382        progress.clear_rows()
383
384        os.chdir(os.path.dirname(tempwav.name))
385
386        cdrdao_args = [audiotools.BIN["cdrdao"], "write",
387                       "--device", options.dev,
388                       "--speed", str(options.speed),
389                       tempcue.name]
390
391        if options.verbosity != 'quiet':
392            subprocess.call(cdrdao_args)
393        else:
394            devnull = open(os.devnull, 'wb')
395            sub = subprocess.Popen(cdrdao_args,
396                                   stdout=devnull,
397                                   stderr=devnull)
398            sub.wait()
399            devnull.close()
400
401        tempcue.close()
402        tempwav.close()
403    else:
404        # writing tracks without a cuesheet,
405        # so extract them to waves and call cdrecord
406
407        if not audiotools.BIN.can_execute(audiotools.BIN['cdrecord']):
408            msg.error(_.ERR_NO_CDRECORD)
409            msg.info(_.ERR_GET_CDRECORD)
410            sys.exit(1)
411
412        exec_args = [audiotools.BIN['cdrecord'],
413                     "-pad",
414                     "-speed", str(options.speed),
415                     "-dev", options.dev,
416                     "-dao", "-audio"]
417
418        temp_pool = []
419        wave_files = []
420
421        if write_offset == 0:
422            queue = audiotools.ExecProgressQueue(msg)
423
424            for audiofile in audiofiles:
425                if isinstance(audiofile, audiotools.WaveAudio):
426                    wave_files.append(audiofile.filename)
427                else:
428                    filename = audiotools.Filename(audiofile.filename)
429                    f = tempfile.mkstemp(suffix='.wav')
430                    temp_pool.append(f)
431                    wave_files.append(f[1])
432                    queue.execute(
433                        function=convert_to_wave,
434                        progress_text=filename.__unicode__(),
435                        completion_output=_.LAB_TRACK2CD_CONVERTED % {
436                            "filename": filename},
437                        audiofile=audiofile,
438                        wave_filename=f[1])
439
440            try:
441                queue.run(max_processes)
442            except KeyboardInterrupt:
443                msg.error(_.ERR_CANCELLED)
444                for (fd, f) in temp_pool:
445                    os.close(fd)
446                    os.unlink(f)
447                sys.exit(1)
448        else:
449            # a list of (PCMReader, total_pcm_frames) pairs
450            try:
451                lossless_tracks = [CD_reader_size(t) for t in audiofiles]
452
453                realigned_stream = audiotools.BufferedPCMReader(
454                    audiotools.PCMReaderWindow(
455                        audiotools.PCMCat([t[0] for t in lossless_tracks]),
456                        write_offset,
457                        sum([t[1] for t in lossless_tracks])))
458
459                for audiofile in audiofiles:
460                    filename = audiotools.Filename(audiofile.filename)
461
462                    progress = audiotools.SingleProgressDisplay(
463                        msg, filename.__unicode__())
464
465                    f = tempfile.mkstemp(suffix=".wav")
466                    temp_pool.append(f)
467                    wave = audiotools.WaveAudio.from_pcm(
468                        f[1],
469                        audiotools.PCMReaderProgress(
470                            audiotools.LimitedPCMReader(
471                                realigned_stream, audiofile.total_frames()),
472                            audiofile.total_frames(),
473                            progress.update))
474                    wave_files.append(wave.filename)
475                    progress.clear_rows()
476                    msg.info(_.LAB_TRACK2CD_CONVERTED % {"filename": filename})
477            except KeyboardInterrupt:
478                progress.clear_rows()
479                msg.error(_.ERR_CANCELLED)
480                for (fd, f) in temp_pool:
481                    os.close(fd)
482                    os.unlink(f)
483                sys.exit(1)
484
485        try:
486            for wave in wave_files:
487                audiotools.open(wave).verify()
488        except (audiotools.UnsupportedFile,
489                audiotools.InvalidFile,
490                IOError):
491            msg.error(_.ERR_TRACK2CD_INVALIDFILE)
492            sys.exit(1)
493
494        exec_args += wave_files
495
496        if options.verbosity != 'quiet':
497            subprocess.call(exec_args)
498        else:
499            devnull = open(os.devnull, 'wb')
500            sub = subprocess.Popen(exec_args,
501                                   stdout=devnull,
502                                   stderr=devnull)
503            sub.wait()
504            devnull.close()
505
506        for (fd, f) in temp_pool:
507            os.close(fd)
508            os.unlink(f)
509