From 622b113efe9b0b912ab92df7a778bbf32477c211 Mon Sep 17 00:00:00 2001 From: ZodiusInfuser Date: Thu, 26 Oct 2023 17:27:03 +0100 Subject: [PATCH] Some work on wav playback --- examples/modules/audio_amp/wav_play.py | 248 ++++-------------------- lib/pimoroni_yukon/modules/audio_amp.py | 195 +++++++++++++++++++ 2 files changed, 231 insertions(+), 212 deletions(-) diff --git a/examples/modules/audio_amp/wav_play.py b/examples/modules/audio_amp/wav_play.py index 41f3d64..916d597 100644 --- a/examples/modules/audio_amp/wav_play.py +++ b/examples/modules/audio_amp/wav_play.py @@ -1,209 +1,36 @@ -import time -import math -import struct -from machine import I2S from pimoroni_yukon import Yukon -from pimoroni_yukon import SLOT6 as SLOT +from pimoroni_yukon import SLOT1 as SLOT from pimoroni_yukon.modules import AudioAmpModule -import micropython -from collections import namedtuple + """ How to play a wave file out of an Audio Amp Module connected to Slot1. """ -WAV_FILE = "ahoy.wav" - - -# Constants -I2S_ID = 0 -BUFFER_LENGTH_IN_BYTES = 20000 -TONE_FREQUENCY_IN_HZ = 1000 -SAMPLE_SIZE_IN_BITS = 16 -SAMPLE_RATE_IN_HZ = 44_100 -SLEEP = 1.0 # The time to sleep between each reading +WAV_FILE_A = "ahoy.wav" +WAV_FILE_B = "Turret_turret_autosearch_4.wav" # Variables yukon = Yukon() # Create a new Yukon object amp = AudioAmpModule() # Create an AudioAmpModule object -audio_out = None # Stores the I2S audio output object created later -samples = None - -is_playing = False - -header = bytearray(44) - - -WavHeader = namedtuple("WavHeader", ("riff_desc", - "file_size", - "wave_fmt_desc", - "chunk_size", - "format", - "channels", - "frequency", - "bytes_per_sec", - "block_alignment", - "bits_per_sample", - "data_desc", - "data_size")) - - -wav = open(WAV_FILE, "rb") - -def parse_wav_file(wav): - header = bytearray(44) - bytes_read = wav.readinto(header) - if bytes_read < 44: - raise OSError("File too small") - - unpacked = WavHeader(*struct.unpack('<4sI8sIHHIIHH4sI', header)) - print(unpacked) - - if unpacked.riff_desc != b"RIFF": - raise OSError("Invalid WAV file") - - if unpacked.wave_fmt_desc != b"WAVEfmt ": - raise OSError("Invalid WAV file") - - if unpacked.data_desc != b"data": - raise OSError("Invalid WAV file") - - if unpacked.chunk_size != 16: - raise OSError("Invalid WAV file") - - if unpacked.format != 0x01: - raise OSError("Invalid WAV file") - - if unpacked.bits_per_sample != 16: - raise OSError(f"Invalid WAV file. Only 16 bits per sample is supported") - - if unpacked.frequency != 44_100 and unpacked.frequency != 48_000: - raise OSError(f"Invalid WAV file frequency of {unpacked.frequency}Hz. Only 44.1Hz or 48KHz audio is supported") - - if unpacked.data_size == 0: - raise OSError("No audio data") - - return unpacked - - -wav_header = parse_wav_file(wav) - -pos = wav.seek(44) # advance to first byte of Data section in WAV file - -# allocate sample array -# memoryview used to reduce heap allocation -wav_samples = bytearray(10000) -wav_samples_mv = memoryview(wav_samples) - - -PLAY = 0 -PAUSE = 1 -RESUME = 2 -STOP = 3 -state = PAUSE - -silence = bytearray(1000) - -def eof_callback(arg): - global state - print("end of audio file") - state = STOP # uncomment to stop looping playback - - - -# Callback function to queue up the next section of audio -def i2s_callback(arg): - global state - global wav_samples_mv - global pos - global wav - if state == PLAY: - num_read = wav.readinto(wav_samples_mv) - # end of WAV file? - if num_read == 0: - # end-of-file, advance to first byte of Data section - pos = wav.seek(44) - play_silence() - state = STOP - micropython.schedule(eof_callback, None) - else: - _ = audio_out.write(wav_samples_mv[:num_read]) - elif state == RESUME: - state = PLAY - play_silence() - elif state == PAUSE: - play_silence() - elif state == STOP: - # cleanup - wav.close() - audio_out.deinit() - print("Done") - else: - print("Not a valid state. State ignored") - - -# Using this for testing: https://github.com/miketeachman/micropython-i2s-examples/tree/master -def make_silence(rate, bits, frequency=1000): - # create a buffer containing the pure tone samples - samples_per_cycle = rate // frequency - sample_size_in_bytes = bits // 8 - samples = bytearray(2 * samples_per_cycle * sample_size_in_bytes) - range = pow(2, bits) // 2 - - if bits == 16: - format = " 1 else I2S.MONO, - rate=wav_header.frequency, - ibuf=BUFFER_LENGTH_IN_BYTES, - ) - - # Enable the switched outputs - amp.enable() # Enable the audio amp. This includes I2C configuration - amp.set_volume(0.5) # Set the output volume of the audio amp - - audio_out.irq(i2s_callback) # i2s_callback is called when buf is emptied - play_silence() - - state = PLAY - # Loop until the BOOT/USER button is pressed while not yukon.is_boot_pressed(): - - yukon.set_led('A', button_toggle) - + + # Has the button been toggled? + if check_button_toggle('A'): + + if not amp.is_playing(): + amp.play(WAV_FILE_A, volume=0.7, loop=False) + yukon.set_led('A', True) + else: + amp.stop() + + # Has the button been toggled? + if check_button_toggle('B'): + + if not amp.is_playing(): + amp.play(WAV_FILE_B, volume=0.7, loop=False) + yukon.set_led('B', True) + else: + amp.stop() + + if not amp.is_playing(): + yukon.set_led('A', False) + yukon.set_led('B', False) + yukon.monitor_once() finally: - if audio_out is not None: - audio_out.deinit() # Put the board back into a safe state, regardless of how the program may have ended yukon.reset() diff --git a/lib/pimoroni_yukon/modules/audio_amp.py b/lib/pimoroni_yukon/modules/audio_amp.py index 941a9d8..d5e0546 100644 --- a/lib/pimoroni_yukon/modules/audio_amp.py +++ b/lib/pimoroni_yukon/modules/audio_amp.py @@ -7,6 +7,9 @@ from machine import Pin from ucollections import OrderedDict from pimoroni_yukon.errors import OverTemperatureError +import os +import struct +from machine import I2S # PAGE 0 Regs PAGE = 0x00 # Device Page Section 8.9.5 @@ -122,11 +125,181 @@ SDOUT_HIZ_9 = 0x45 # Slots Control Section 8.9.116 TG_EN = 0x47 # Thermal Detection Enable Section 8.9.117 +# Modified from: https://github.com/miketeachman/micropython-i2s-examples/blob/master/examples/wavplayer.py +class WavPlayer: + PLAY = 0 + PAUSE = 1 + RESUME = 2 + FLUSH = 3 + STOP = 4 + + def __init__(self, id, sck_pin, ws_pin, sd_pin, ibuf, root="/"): + self.id = id + self.sck_pin = sck_pin + self.ws_pin = ws_pin + self.sd_pin = sd_pin + self.ibuf = ibuf + self.root = root.rstrip("/") + "/" + self.state = WavPlayer.STOP + self.wav = None + self.loop = False + self.format = None + self.sample_rate = None + self.bits_per_sample = None + self.first_sample_offset = None + self.num_read = 0 + self.sbuf = 1000 + self.nflush = 0 + self.audio_out = None + + # allocate a small array of blank audio samples used for silence + self.silence_samples = bytearray(self.sbuf) + + # allocate audio sample array buffer + self.wav_samples_mv = memoryview(bytearray(10000)) + + def i2s_callback(self, arg): + if self.state == WavPlayer.PLAY: + self.num_read = self.wav.readinto(self.wav_samples_mv) + # end of WAV file? + if self.num_read == 0: + # end-of-file + if self.loop == False: + self.state = WavPlayer.FLUSH + else: + # advance to first byte of Data section + _ = self.wav.seek(self.first_sample_offset) + _ = self.audio_out.write(self.silence_samples) + else: + _ = self.audio_out.write(self.wav_samples_mv[: self.num_read]) + elif self.state == WavPlayer.RESUME: + self.state = WavPlayer.PLAY + _ = self.audio_out.write(self.silence_samples) + elif self.state == WavPlayer.PAUSE: + _ = self.audio_out.write(self.silence_samples) + elif self.state == WavPlayer.FLUSH: + # Flush is used to allow the residual audio samples in the + # internal buffer to be written to the I2S peripheral. This step + # avoids part of the sound file from being cut off + if self.nflush > 0: + self.nflush -= 1 + _ = self.audio_out.write(self.silence_samples) + else: + self.wav.close() + self.audio_out.deinit() + self.state = WavPlayer.STOP + elif self.state == WavPlayer.STOP: + pass + else: + raise SystemError("Internal error: unexpected state") + self.state == WavPlayer.STOP + + def parse(self, wav_file): + chunk_ID = wav_file.read(4) + if chunk_ID != b"RIFF": + raise ValueError("WAV chunk ID invalid") + chunk_size = wav_file.read(4) + format = wav_file.read(4) + if format != b"WAVE": + raise ValueError("WAV format invalid") + sub_chunk1_ID = wav_file.read(4) + if sub_chunk1_ID != b"fmt ": + raise ValueError("WAV sub chunk 1 ID invalid") + sub_chunk1_size = wav_file.read(4) + audio_format = struct.unpack("WAV converters add + # binary data before "data". So, read a fairly large + # block of bytes and search for "data". + + binary_block = wav_file.read(200) + offset = binary_block.find(b"data") + if offset == -1: + raise ValueError("WAV sub chunk 2 ID not found") + + self.first_sample_offset = 44 + offset + + def queue(self, wav_file, loop=False): + if os.listdir(self.root).count(wav_file) == 0: + raise ValueError("%s: not found" % wav_file) + if self.state == WavPlayer.PLAY: + raise ValueError("already playing a WAV file") + elif self.state == WavPlayer.PAUSE: + raise ValueError("paused while playing a WAV file") + else: + self.wav = open(self.root + wav_file, "rb") + self.loop = loop + self.parse(self.wav) + + self.audio_out = I2S( + self.id, + sck=self.sck_pin, + ws=self.ws_pin, + sd=self.sd_pin, + mode=I2S.TX, + bits=self.bits_per_sample, + format=self.format, + rate=self.sample_rate, + ibuf=self.ibuf, + ) + + # advance to first byte of Data section in WAV file + _ = self.wav.seek(self.first_sample_offset) + self.audio_out.irq(self.i2s_callback) + self.nflush = self.ibuf // self.sbuf + 1 + self.state = WavPlayer.PAUSE + _ = self.audio_out.write(self.silence_samples) + + def resume(self): + if self.state != WavPlayer.PAUSE: + raise ValueError("can only resume when WAV file is paused") + else: + self.state = WavPlayer.RESUME + + def pause(self): + if self.state == WavPlayer.PAUSE: + pass + elif self.state != WavPlayer.PLAY: + raise ValueError("can only pause when WAV file is playing") + + self.state = WavPlayer.PAUSE + + def stop(self): + self.state = WavPlayer.FLUSH + + def isplaying(self): + if self.state != WavPlayer.STOP: + return True + else: + return False + + def terminate(self): + if self.audio_out is not None: + self.audio_out.deinit() + + class AudioAmpModule(YukonModule): NAME = "Audio Amp" AMP_I2C_ADDRESS = 0x38 TEMPERATURE_THRESHOLD = 50.0 + I2S_ID = 0 + BUFFER_LENGTH_IN_BYTES = 20000 # | ADC1 | ADC2 | SLOW1 | SLOW2 | SLOW3 | Module | Condition (if any) | # |-------|-------|-------|-------|-------|----------------------|-----------------------------| @@ -136,6 +309,7 @@ def is_module(adc1_level, adc2_level, slow1, slow2, slow3): return adc1_level == ADC_FLOAT and slow1 is IO_LOW and slow2 is IO_HIGH and slow3 is IO_HIGH def __init__(self): + self.player = None super().__init__() def initialise(self, slot, adc1_func, adc2_func): @@ -153,6 +327,12 @@ def initialise(self, slot, adc1_func, adc2_func): self.I2S_DATA = slot.FAST1 self.I2S_CLK = slot.FAST2 self.I2S_FS = slot.FAST3 + + self.player = WavPlayer(id=self.I2S_ID, + sck_pin=self.I2S_CLK, + ws_pin=self.I2S_FS, + sd_pin=self.I2S_DATA, + ibuf=self.BUFFER_LENGTH_IN_BYTES) # Pass the slot and adc functions up to the parent now that module specific initialisation has finished super().initialise(slot, adc1_func, adc2_func) @@ -161,6 +341,9 @@ def reset(self): self.__slow_sda.init(Pin.OUT, value=True) self.__slow_scl.init(Pin.OUT, value=True) self.__amp_en.init(Pin.OUT, value=False) + + if self.player is not None: + self.player.terminate() def enable(self): self.__amp_en.value(True) @@ -232,6 +415,18 @@ def disable(self): def is_enabled(self): return self.__amp_en.value() == 1 + + def play(self, wav_file, volume=0.5, loop=False): + self.player.queue(wav_file, loop=loop) + self.enable() + self.set_volume(volume) + self.player.resume() + + def stop(self): + self.player.stop() + + def is_playing(self): + return self.player.isplaying() def exit_soft_shutdown(self): self.write_i2c_reg(MODE_CTRL, 0x80) # Calling this after a play seems to wake the amp up, but adds around 16ms