source: trunk/morituri/program/cdparanoia.py @ 367

Revision 367, 14.1 KB checked in by thomas, 3 years ago (diff)
  • morituri/program/cdparanoia.py: Add some debug.
  • morituri/common/encode.py: Add more debug. Handle the case where peak is full scale, and peakdB thus 0, which triggered not setting self.peak.
Line 
1# -*- Mode: Python; test-case-name: morituri.test.test_program_cdparanoia -*-
2# vi:si:et:sw=4:sts=4:ts=4
3
4# Morituri - for those about to RIP
5
6# Copyright (C) 2009 Thomas Vander Stichele
7
8# This file is part of morituri.
9#
10# morituri is free software: you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by
12# the Free Software Foundation, either version 3 of the License, or
13# (at your option) any later version.
14#
15# morituri is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with morituri.  If not, see <http://www.gnu.org/licenses/>.
22
23import os
24import re
25import stat
26import shutil
27import subprocess
28import tempfile
29
30from morituri.common import task, log, common
31from morituri.extern import asyncsub
32
33class FileSizeError(Exception):
34    """
35    The given path does not have the expected size.
36    """
37    def __init__(self, path):
38        self.args = (path, )
39        self.path = path
40
41class ReturnCodeError(Exception):
42    """
43    The program had a non-zero return code.
44    """
45    def __init__(self, returncode):
46        self.args = (returncode, )
47        self.returncode = returncode
48
49_PROGRESS_RE = re.compile(r"""
50    ^\#\#: (?P<code>.+)\s      # function code
51    \[(?P<function>.*)\]\s@\s     # function name
52    (?P<offset>\d+)        # offset
53""", re.VERBOSE)
54
55# from reading cdparanoia source code, it looks like offset is reported in
56# number of single-channel samples, ie. 2 bytes per unit, and absolute
57
58class ProgressParser(object):
59    read = 0 # last [read] frame
60    wrote = 0 # last [wrote] frame
61    _nframes = None # number of frames read on each [read]
62    _firstFrames = None # number of frames read on first [read]
63    reads = 0 # total number of reads
64
65    def __init__(self, start, stop):
66        """
67        @param start:  first frame to rip
68        @type  start:  int
69        @param stop:   last frame to rip (inclusive)
70        @type  stop:   int
71        """
72        self.start = start
73        self.stop = stop
74
75        # FIXME: privatize
76        self.read = start
77
78        self._reads = {} # read count for each sector
79
80
81    def parse(self, line):
82        """
83        Parse a line.
84        """
85        m = _PROGRESS_RE.search(line)
86        if m:
87            # code = int(m.group('code'))
88            function = m.group('function')
89            wordOffset = int(m.group('offset'))
90            if function == 'read':
91                self._parse_read(wordOffset)
92            elif function == 'wrote':
93                self._parse_wrote(wordOffset)
94
95    def _parse_read(self, wordOffset):
96        if wordOffset % common.WORDS_PER_FRAME != 0:
97            print 'THOMAS: not a multiple of %d: %d' % (
98                common.WORDS_PER_FRAME, wordOffset)
99            return
100
101        frameOffset = wordOffset / common.WORDS_PER_FRAME
102
103        # set nframes if not yet set
104        if self._nframes is None and self.read != 0:
105            self._nframes = frameOffset - self.read
106
107        # set firstFrames if not yet set
108        if self._firstFrames is None:
109            self._firstFrames = frameOffset - self.start
110
111        markStart = None
112        markEnd = None
113
114        # verify it either read nframes more or went back for verify
115        if frameOffset > self.read:
116            delta = frameOffset - self.read
117            if self._nframes and delta != self._nframes:
118                # print 'THOMAS: Read %d frames more, not %d' % (delta, self._nframes)
119                # my drive either reads 7 or 13 frames
120                pass
121
122            # update our read sectors hash
123            markStart = self.read
124            markEnd = frameOffset
125        else:
126            # went back to verify
127            # we could use firstFrames as an estimate on how many frames this
128            # read, but this lowers our track quality needlessly where
129            # EAC still reports 100% track quality
130            markStart = frameOffset # - self._firstFrames
131            markEnd = frameOffset
132
133        # FIXME: doing this is way too slow even for a testcase, so disable
134        if False:
135            for frame in range(markStart, markEnd):
136                if not frame in self._reads.keys():
137                    self._reads[frame] = 0
138                self._reads[frame] += 1
139
140        # cdparanoia reads quite a bit beyond the current track before it
141        # goes back to verify; don't count those
142        if markEnd > self.stop:
143            markEnd = self.stop
144        if markStart > self.stop:
145            markStart = self.stop
146
147        self.reads += markEnd - markStart
148
149        # update our read pointer
150        self.read = frameOffset
151
152    def _parse_wrote(self, wordOffset):
153        # cdparanoia outputs most [wrote] calls with one word less than a frame
154        frameOffset = (wordOffset + 1) / common.WORDS_PER_FRAME
155        self.wrote = frameOffset
156       
157    def getTrackQuality(self):
158        """
159        Each frame gets read twice.
160        More than two reads for a frame reduce track quality.
161        """
162        frames = self.stop - self.start + 1
163        reads = self.reads
164
165        # don't go over a 100%; we know cdparanoia reads each frame at least
166        # twice
167        return min(frames * 2.0 / reads, 1.0)
168
169
170# FIXME: handle errors
171class ReadTrackTask(task.Task):
172    """
173    I am a task that reads a track using cdparanoia.
174
175    @ivar reads: how many reads were done to rip the track
176    """
177
178    description = "Reading Track"
179    quality = None # set at end of reading
180
181    def __init__(self, path, table, start, stop, offset=0, device=None):
182        """
183        Read the given track.
184
185        @param path:   where to store the ripped track
186        @type  path:   unicode
187        @param table:  table of contents of CD
188        @type  table:  L{table.Table}
189        @param start:  first frame to rip
190        @type  start:  int
191        @param stop:   last frame to rip (inclusive)
192        @type  stop:   int
193        @param offset: read offset, in samples
194        @type  offset: int
195        @param device: the device to rip from
196        @type  device: str
197        """
198        assert type(path) is unicode, "%r is not unicode" % path
199
200        self.path = path
201        self._table = table
202        self._start = start
203        self._stop = stop
204        self._offset = offset
205        self._parser = ProgressParser(start, stop)
206        self._device = device
207
208        self._buffer = "" # accumulate characters
209        self._errors = []
210
211    def start(self, runner):
212        task.Task.start(self, runner)
213
214        # find on which track the range starts and stops
215        startTrack = 0
216        startOffset = 0
217        stopTrack = 0
218        stopOffset = self._stop
219
220        for i, t in enumerate(self._table.tracks):
221            if self._table.getTrackStart(i + 1) <= self._start:
222                startTrack = i + 1
223                startOffset = self._start - self._table.getTrackStart(i + 1)
224            if self._table.getTrackEnd(i + 1) <= self._stop:
225                stopTrack = i + 1
226                stopOffset = self._stop - self._table.getTrackStart(i + 1)
227
228        self.debug('Ripping from %d to %d (inclusive)',
229            self._start, self._stop)
230        self.debug('Starting at track %d, offset %d',
231            startTrack, startOffset)
232        self.debug('Stopping at track %d, offset %d',
233            stopTrack, stopOffset)
234
235        bufsize = 1024
236        argv = ["cdparanoia", "--stderr-progress",
237            "--sample-offset=%d" % self._offset, ]
238        if self._device:
239            argv.extend(["--force-cdrom-device", self._device, ])
240        argv.extend(["%d[%s]-%d[%s]" % (
241                startTrack, common.framesToHMSF(startOffset),
242                stopTrack, common.framesToHMSF(stopOffset)),
243            self.path])
244        self.debug('Running %s' % (" ".join(argv), ))
245        self._popen = asyncsub.Popen(argv,
246            bufsize=bufsize,
247            stdin=subprocess.PIPE, stdout=subprocess.PIPE,
248            stderr=subprocess.PIPE, close_fds=True)
249
250        self.runner.schedule(1.0, self._read, runner)
251
252    def _read(self, runner):
253        ret = self._popen.recv_err()
254        if not ret:
255            if self._popen.poll() is not None:
256                self._done()
257                return
258            self.runner.schedule(0.01, self._read, runner)
259            return
260
261        self._buffer += ret
262
263        # parse buffer into lines if possible, and parse them
264        if "\n" in self._buffer:
265            lines = self._buffer.split('\n')
266            if lines[-1] != "\n":
267                # last line didn't end yet
268                self._buffer = lines[-1]
269                del lines[-1]
270            else:
271                self._buffer = ""
272
273            for line in lines:
274                self._parser.parse(line)
275
276            num = float(self._parser.wrote) - self._start
277            den = float(self._stop) - self._start
278            progress = num / den
279            if progress < 1.0:
280                self.setProgress(progress)
281
282        # 0 does not give us output before we complete, 1.0 gives us output
283        # too late
284        self.runner.schedule(0.01, self._read, runner)
285
286    def _poll(self, runner):
287        if self._popen.poll() is None:
288            self.runner.schedule(1.0, self._poll, runner)
289            return
290
291        self._done()
292
293    def _done(self):
294        self.setProgress(1.0)
295
296        # check if the length matches
297        size = os.stat(self.path)[stat.ST_SIZE]
298        # wav header is 44 bytes
299        offsetLength = self._stop - self._start + 1
300        expected = offsetLength * common.BYTES_PER_FRAME + 44
301        if size != expected:
302            # FIXME: handle errors better
303            self.warning('file size %d did not match expected size %d',
304                size, expected)
305            if (size - expected) % common.BYTES_PER_FRAME == 0:
306                print 'ERROR: %d frames difference' % (
307                    (size - expected) / common.BYTES_PER_FRAME)
308
309            self.exception = FileSizeError(self.path)
310
311        if not self.exception and self._popen.returncode != 0:
312            if self._errors:
313                print "\n".join(self._errors)
314            else:
315                self.warning('exit code %r', self._popen.returncode)
316                self.exception = ReturnCodeError(self._popen.returncode)
317
318        self.quality = self._parser.getTrackQuality()
319           
320        self.stop()
321        return
322
323class ReadVerifyTrackTask(task.MultiSeparateTask):
324    """
325    I am a task that reads and verifies a track using cdparanoia.
326
327    @ivar path:         the path where the file is to be stored.
328    @ivar checksum:     the checksum of the track; set if they match.
329    @ivar testchecksum: the test checksum of the track.
330    @ivar copychecksum: the copy checksum of the track.
331    @ivar peak:         the peak level of the track
332    """
333
334    checksum = None
335    testchecksum = None
336    copychecksum = None
337    peak = None
338    quality = None
339
340    _tmpwavpath = None
341    _tmppath = None
342
343    def __init__(self, path, table, start, stop, offset=0, device=None, profile=None, taglist=None):
344        """
345        @param path:    where to store the ripped track
346        @type  path:    str
347        @param table:   table of contents of CD
348        @type  table:   L{table.Table}
349        @param start:   first frame to rip
350        @type  start:   int
351        @param stop:    last frame to rip (inclusive)
352        @type  stop:    int
353        @param offset:  read offset, in samples
354        @type  offset:  int
355        @param device:  the device to rip from
356        @type  device:  str
357        @param profile: the encoding profile
358        @type  profile: L{encode.Profile}
359        @param taglist: a list of tags
360        @param taglist: L{gst.TagList}
361        """
362        task.MultiSeparateTask.__init__(self)
363
364        self.path = path
365
366        if taglist:
367            self.debug('read and verify with taglist %r', taglist)
368        # FIXME: choose a dir on the same disk/dir as the final path
369        fd, tmppath = tempfile.mkstemp(suffix='.morituri.wav')
370        tmppath = unicode(tmppath)
371        os.close(fd)
372        self._tmpwavpath = tmppath
373
374        # here to avoid import gst eating our options
375        from morituri.common import checksum
376
377        self.tasks = []
378        self.tasks.append(
379            ReadTrackTask(tmppath, table, start, stop,
380                offset=offset, device=device))
381        self.tasks.append(checksum.CRC32Task(tmppath))
382        t = ReadTrackTask(tmppath, table, start, stop,
383            offset=offset, device=device)
384        t.description = 'Verifying track...'
385        self.tasks.append(t)
386        self.tasks.append(checksum.CRC32Task(tmppath))
387
388        fd, tmpoutpath = tempfile.mkstemp(suffix='.morituri.%s' %
389            profile.extension)
390        tmpoutpath = unicode(tmpoutpath)
391        os.close(fd)
392        self._tmppath = tmpoutpath
393
394        # here to avoid import gst eating our options
395        from morituri.common import encode
396
397        self.tasks.append(encode.EncodeTask(tmppath, tmpoutpath, profile,
398            taglist=taglist))
399        # make sure our encoding is accurate
400        self.tasks.append(checksum.CRC32Task(tmpoutpath))
401
402        self.checksum = None
403
404    def stop(self):
405        if not self.exception:
406            self.quality = max(self.tasks[0].quality, self.tasks[2].quality)
407            self.peak = self.tasks[4].peak
408            self.debug('peak: %r', self.peak)
409
410            self.testchecksum = c1 = self.tasks[1].checksum
411            self.copychecksum = c2 = self.tasks[3].checksum
412            if c1 == c2:
413                self.info('Checksums match, %08x' % c1)
414                self.checksum = self.testchecksum
415            else:
416                self.error('read and verify failed')
417
418            if self.tasks[5].checksum != self.checksum:
419                self.error('Encoding failed, checksum does not match')
420
421            # delete the unencoded file
422            os.unlink(self._tmpwavpath)
423
424            try:
425                shutil.move(self._tmppath, self.path)
426            except Exception, e:
427                self._exception = e
428        else:
429            self.debug('stop: exception %r', self.exception)
430
431        task.MultiSeparateTask.stop(self)
Note: See TracBrowser for help on using the repository browser.