1. 程式人生 > >一個線上音樂軟體的故事(四、現在就可以開始編碼了嗎?)

一個線上音樂軟體的故事(四、現在就可以開始編碼了嗎?)

看起來一切已經就緒,我們選擇了最熟悉的各種元件庫,解決了音樂源的問題,似乎可以開始大刀闊斧的開工了。且慢!現在還不行,還要解決一些問題才能開工。我把這些問題稱為技術障礙,必須先克服這些技術障礙,才能開始動手編碼。

一、如何播放音訊檔案?

首先需要確認的是,音訊播放。這裡我們假設電腦上已經安裝了FfmpegPyAudio這兩個元件庫,那麼問題是如何在Python中呼叫這些庫播放音樂?

我做了個小實驗,用了下面這段程式碼:

 

def play_from_url(song_url):
    """
    從 URL 地址播放一段音樂

    :param str song_url: 音樂地址
    """
    
request = Request(song_url)
    pipe = urlopen(url=request, timeout=DEFAULT_TIMEOUT)
    with NamedTemporaryFile("w+b"

suffix=".wav"as f:
        f.seek(0)
        while True:
            data = pipe.read(1024)
            if not 
len(data):
                break
            
f.write(data)
        subprocess.call([

            PLAYER, "-nodisp""-autoexit""-hide_banner", f.name])

 

做完這個實驗,我發現一切都工作的很不錯,程式的執行過程是這樣的:首先根據音樂的URL資訊從伺服器上讀取音訊資料,通過Python的臨時檔案儲存在電腦上,再通過子程序呼叫的方式用本地安裝的播放器播放音訊檔案。

這段程式碼的確能夠工作,但存在一(很)些(多)問題。最顯而易見的是在命令列模式下這段程式碼執行的很好,但是在GUI模式下,他會卡住一小會兒,等快取結束就又正常了,這是為什麼呢?答案就是執行緒!仔細看這段程式碼,while迴圈退出的標記是data的長度為0,那麼在有資料讀取的過程中,迴圈會一直執行,這在命令模式下顯然沒什麼問題,但是在GUI模式下,這樣的迴圈會導致GUI介面無法操作,實際上是因為快取迴圈阻塞了Qt主執行緒的迴圈過程,那麼畫面肯定就無法操作了!

二、齊頭並進,我們需要多執行緒

為了解決上面的問題,我們需要用到執行緒。我們希望在快取檔案的同時不影響Qt主執行緒迴圈,使用者能在主介面上通過滑鼠或鍵盤進行操作。

Python為我們提供了完整的多執行緒操作機制,但如何在Python中使用多執行緒與這個故事並沒有太大的關係,如果你現在對Python的多執行緒機制還不熟悉,又想看完這個故事,我建議可以先看看《Python cookbook 第三版》的第十二章,在那裡可以學到很多併發程式設計的技巧。

另外我並不打算使用Python原生的多執行緒方案,而是使用PySide提供的QThread類進行多執行緒開發,理由是在多執行緒通訊機制方面QThread提供與PySide UI一樣的訊號-機制,後文我會介紹訊號-機制的使用方法。

QThread的使用與PythonThread使用方式類似,都是通過執行緒類建立物件,呼叫start()介面方法啟動執行緒,執行緒啟動之後是從run()入口方法開始執行,通過呼叫wait()介面方法可以讓執行緒進入等待狀態,直到執行緒全部執行完畢或遇到quit()exit()方法呼叫時執行緒才會退出。但請注意並不是呼叫quit()或者exit()介面方法後執行緒就會立即退出,這可以通過QThreadisRunning()方法來判斷。

有了這些方法,我們就可以修改上面的程式碼,用Player類封裝:

 

class Player(QThread):

def play_from_url(song_url):
        # coder here

 

    def run(self, *args, **kwargs):
        """
        開始播放
        """
        
self.play_from_url()

 

然後建立Player的例項,再呼叫例項的start()方法就能啟動播放執行緒。在這個軟體裡面有很多地方都會用到執行緒操作,比如下載、播放等待動畫、快取音樂等等,後面還會講到與執行緒有關的內容。

三、音樂播放到哪裡了?

通過使用多執行緒,我們的播放器在GUI介面中也能正常播放音樂,而且所有的介面操作都不會受到影響。但是其他問題依然存在!我們的播放器沒有辦法顯示播放進度!這顯然無法滿足使用需求。

通過分析上面的播放程式碼也不難發現,我播放音樂是通過子程序呼叫本地播放器實現的,這種方式對於播放背景音樂、特效音樂來說完全沒有問題,因為播放這些音樂不需要使用者干預,使用者只要設定播放或者不播放就行,而對一款音樂播放軟體來說,這是無論如何都不能接受的,這時就需要請出PyAudio

PyAudio不僅能播放音樂還能錄音。通過閱讀PyAudio官方文件和官方提供的範例就能大致理解如何通過PyAudio播放音樂,官方提供的範例程式碼很短,但確實能播放WAV檔案,細心的你很快就能發現,程式碼中同樣沒有現成的播放進度資料可以使用。

下面是PyAudio官方提供的範例程式碼:

"""PyAudio Example: Play a WAVE file."""

import pyaudio
import wave
import sys

CHUNK = 1024

if len(sys.argv) < 2:
    print("Plays a wave file.\n\nUsage: %s filename.wav" %

           sys.argv[0])
    sys.exit(-1)

wf = wave.open(sys.argv[1], 'rb')

p = pyaudio.PyAudio()
stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
                channels=wf.getnchannels(),
                rate=wf.getframerate(),
                output=True)

data = wf.readframes(CHUNK)
while data != '':
    stream.write(data)
    data = wf.readframes(CHUNK)

stream.stop_stream()
stream.close()
p.terminate()

 

從官方程式碼中可以發現,建立PyAudio物件後,就能通過open()方法(注意該方法所提供的引數分別是:取樣格式、聲道數、位元速率、是否輸出)獲得播放音樂的資料流物件,我們可以稱之為音效卡資料流。然後不斷地向音效卡資料流寫入資料,我們就能通過電腦音響或耳機聽到音樂了。

這段程式碼中的另一個重點是向音效卡流寫入的資料,這些資料並不是直接從檔案讀取到的資料,而是音訊檔案的音訊資料幀,音訊資料幀是struct型別資料,一般會包含幀頭、CRC校驗(由幀頭第16bit決定是否有CRC校驗)、幀資料、VBR頭這幾個重要資訊,因此在向音效卡資料流寫入資料時,一定要確認寫入的是幀資料。

如果我們能夠獲得每個幀資料播放持續的時間,就能通過下面的公式計算獲得當前播放的時間長度:

當前播放的時間長度(秒) 播放過的幀數 每幀持續時間(毫秒) * 1000

一般來說幀的持續時間為2.5ms~60ms,對於一首音樂我們可以通過下面的計算公式來計算每幀的持續時間:

每幀持續時間(毫秒) 每幀取樣數 取樣頻率 * 1000

有了上面的兩組公式就能順利計算當前播放時間,在播放介面上就能像商業播放軟體那樣動態顯示音樂的播放時間。

 

def play(self):
    """
    從 self.start_position 時間開始播放音樂
    """
    
if not self.is_valid:
        return False

    self.is_playing = True
    self.emit(SIGNAL('before_play()'))
    
self.time = self.start_position

    audio_stream = self.audio.open(
        format=self.audio.get_format_from_width(

            self.audio_segment.sample_width),
        channels=self.audio_segment.channels,
        rate=self.audio_segment.frame_rate,
        output=True
    )

    index = 0
    # for chunk in self.chunks:
    
while True:
        if not self.is_playing:
            
break

        while 
self.is_paused:
            
sleep(0.5)
            continue

        if 
self.time >= self.duration:
            
self.emit(SIGNAL('play_finished()'))
            
break
        
self.time += self.chunk_duration

        if index < len(self.chunks):
            
audio_stream.write(self.chunks[index].raw_data)
            index += 1

        try:
            self.emit(SIGNAL('playing()'))
        
except Exception as e:
            continue

    
audio_stream.close()
    self.is_playing = False
    self.emit(SIGNAL('stopped()'))

 

從上面的程式碼中可以看出來,在播放時把每次播放過的chunk的播放時間加在一起,就是當前播放的時間進度,只要在播放介面上獲取這個值,就能用來設定播放進度。

四、為什麼要等很久才播放音樂?

如果你做了第一小節中的那個實驗,你會發現這段程式碼除了對GUI介面有影響,還有一個問題,就是音樂不是立即開始播放的,你要等待一會兒才能聽到音樂,並且根據音樂位元速率、時間長度的不同,有可能要等待比較長的時間才能聽到音樂,而我們以前用過的線上音樂播放軟體則很快就能開始播放音樂。

這是由我們的設計結構造成的,我們的方法是把檔案全部快取之後再播放,這就是導致要等待很久的主要原因!如何縮短等待時間呢?

方法就是邊快取邊播放,在檔案第一次快取成功之後,就立即讀取出來,分析出能夠讀取到的所有的音訊幀資料,將其儲存在一個數據列隊中,在一個新的執行緒中將音訊資料幀迴圈寫入音效卡資料流開始播放;第二次快取成功之後也是這樣操作,只是讀取資料時不要從頭開始,而是從上次讀取結束的地方開始;第三次...第四次...;直到所有的幀資料都被讀取到資料列隊中。

每次快取的資料量可以調整到合適的大小,一般在200KB400KB之間,對絕大多數網路環境來說,快取這麼多資料一般在1-2秒左右即可完成,隨後就能聽到音樂,使用者體驗自然要好很多。

五、快取真的成功了嗎?

音樂檔案肯定需要快取,但是不能作為臨時檔案快取,特別是不能設定為有超時時間的快取檔案,因為檔案會在超時時間之後將被刪除。音訊檔案要快取在指定的快取目錄中,並且下次播放時還要能找到這個檔案。

改變快取檔案的位置並不複雜,但請注意如果音訊檔案已經開始快取,但並沒有快取完畢,使用者就切換到另一首音樂繼續播放,那麼這次快取的檔案肯定已經在快取目錄中存在,下次再播放這首音樂時將按優先讀取快取的原則,載入這個快取檔案進行播放,這顯然有很大的問題,因為使用者無法聽完整首音樂就會結束。所以需要確認檔案是否成功快取完畢,否則下次播放同一首音樂的時候要覆蓋寫入,重新快取才行。

 

def check_audio_cache(self):
    """
    檢查快取檔案是否存在,且是否快取完畢
    
:return: False 或 AudioSegment 物件
    """
    
cache_song = QM_DEFAULT_CACHE_PATH + self.song_info['filename']
    if not os.path.isfile(cache_song):
        return False
    if os.path.getsize(cache_song) < 1024:
        return False

    audio_seg = AudioSegment.from_file(cache_song)

    interval = int(self.song_info['interval']) * 1000
    duration = audio_seg.duration_seconds * 1000
    if duration > interval:
        return audio_seg
    else:
        return False

 

那我們就先判斷檔案是否已經快取完畢,從上面的程式碼可以看出來,首先確認快取檔案是否存在,然後再確認檔案大小,如果小於1024位元組,就不用繼續檢查了,因為後面的檢查比較消耗時間,當然1024這個值可以調整為更合理的值。如果能建立AudioSegment物件,那麼就把音訊檔案的播放時長和上面取得的音樂資訊中的時長進行比較,一般音訊檔案的時長會一直讀取到毫秒級,而從騰訊伺服器上讀取到的音訊資訊只會記錄到秒,那麼判斷的標準就很清楚了,只要實際時長大於音訊資訊中的時長,那麼快取就是成功完成的。

AudioSegment 是 Pydub庫中的核心類,用於建立音訊剪輯物件,生成音訊資料幀以及獲取很多其他音訊資訊。在GitHub上,Pydub的作者給出了很多範例和比較詳細的API文件,建議去看一看。

六、簡約時尚的介面

簡約時尚可沒那麼簡單!構建GUI介面的工作絕對是非常麻煩、細緻、沒有絕對標準、考驗你的審美觀的工作,為了讓這項工作變得比較簡單,就只有祭出模仿這個神器!從上面唯一的一張圖片可以看出來,模仿的是QQ音樂PC版的GUI介面。

看上去需要請個PS高手給我切出很多圖片來才能構建這個GUI介面。其實用不著,因為PySide有很多設定GUI元件介面樣式的函式,可以直接呼叫它們來設定介面的樣式,但是這很麻煩,而且不易於從外部改變介面的樣式風格。更值的慶幸的是PySide允許我們通過設定QApplicationQWidget元件的CSS樣式表屬性來改變GUI元件的顯示樣式,這為我們構建簡約時尚的GUI介面提供了強有力的支援!

如果你學過CSS,那麼後面的內容對你來說就很容易理解。如果你沒有學過,那也沒關係,Qt官方有文件告訴你他們的元件都支援哪些CSS樣式表屬性,總的來說像:colorbackground-colorpaddingmarginborderborder-radiusheightwidthmin-widthmax-width等一些常用的屬性PySide都是支援的。這些屬性的具體值的設定可以去看CSS樣式表手冊,裡面不僅文件齊全,還有很多範例。由於CSS的設計初衷是給HTML用於定義Web頁面樣式使用的,因此手冊中的範例都是用HTML文件的方式實現的。

在這個故事中,我們要讓CSS應用在PySideGUI元件上,使用方式肯定和HTML不一樣。你可以在 http://doc.qt.io/qt-4.8/stylesheet-examples.html 這裡看到很多如何在PySide上使用CSS樣式的範例。