diff --git a/README.md b/README.md index b840457..4fe3bf4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # afskmodem -A software-defined Audio Frequency-Shift Keying modem designed for analog FM radios. Uses the device's default audio input and output. +A software-defined Audio Frequency-Shift Keying modem designed for analog FM +radios. Uses the device's default audio input and output. [Source code](https://github.com/lavajuno/afskmodem) @@ -34,8 +35,28 @@ from afskmodem import Receiver r = Receiver(1200, 14000, 11000) ``` -> Note: Although it is possible to change these parameters, the defaults usually perform the best. -> The input device's volume should be adjusted before changing the sensitivity of Receiver. +> Note: Although it is possible to change these parameters, the defaults usually +> perform the best. +> The input device's volume should be adjusted before changing the sensitivity +> of Receiver. + +### Writing to a .wav file with Transmitter: +```python +from afskmodem import Transmitter +t = Transmitter(1200) +t.save("Hello World!".encode("ascii", "ignore"), "myfile.wav") +``` + +### Reading from a .wav file with Receiver: +```python +from afskmodem import Receiver +r = Receiver(1200) +recv_data = r.read("myfile.wav") +print(recv_data.decode("ascii", "ignore")) +``` + +> Note: Input files for Receiver.read() must be 48khz 16-bit mono. +> Transmitter.write() outputs files in this format. # Supported Baud Rates afskmodem supports common baud rates like 300, 600, 1200, 2400, 4800, and 9600. @@ -52,14 +73,19 @@ and provides functionality for receiving and decoding messages. `baud_rate`: (required, int, 300-12000) Baud rate for this Receiver -`amp_start_threshold`: (optional, int, 0-32768) Amplitude to detect start of signal +`amp_start_threshold`: (optional, int, 0-32768) Amplitude to detect start of +signal `amp_end_threshold`: (optional, int, 0-32768) Amplitude to detect end of signal ### Member Functions -`receive(timeout: float) -> bytes` - Listens and decodes a message. Takes a timeout in seconds. +`receive(timeout: float) -> bytes` - Listens and decodes a message. Takes a +timeout in seconds. + +`read(filename: str) -> bytes` - Listens and decodes a message, reading input +from the specified .wav file. Input file must be 48khz 16-bit mono. ## Transmitter @@ -70,8 +96,12 @@ and provides functionality for encoding and sending messages. `baud_rate`: (required, int, 300-12000) Baud rate for this Transmitter -`training_sequence_time`: (optional, float) Length of the training sequence in seconds. +`training_sequence_time`: (optional, float) Length of the training sequence in +seconds ### Member Functions -`transmit(data: bytes)`: Encodes and transmits a message +`transmit(data: bytes)`: Encodes and transmits a message. + +`write(data: bytes, filename: str)`: Encodes and transmits a message, saving +the audio to a .wav file instead of playing it. diff --git a/afskmodem.py b/afskmodem.py index 0999d84..0cf52f7 100644 --- a/afskmodem.py +++ b/afskmodem.py @@ -2,12 +2,15 @@ afskmodem.py https://lavajuno.github.io/afskmodem Author: Juno Meifert + Modified: 2024-04-15 """ import pyaudio + +import wave from datetime import datetime -# Log level (0: Info (Recommended), 1: Warn, 2: Error, 3: None) +# Log level (0: Debug, 1: Info (Recommended), 2: Warn, 3: Error, 4: Fatal) LOG_LEVEL = 0 """ @@ -21,19 +24,18 @@ def __init__(self, class_name: str): def __print(self, level: int, message: str): if(level >= LOG_LEVEL): output = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - output += " (afskmodem) " match level: case 1: - output += "[ INFO ] " + output += " [ INFO ] " case 2: - output += "[ WARN ] " + output += " [ WARN ] " case 3: - output += "[ ERROR ] " + output += " [ ERROR ] " case 4: - output += "[ FATAL ] " + output += " [ FATAL ] " case _: - output += "[ DEBUG ] " - output += self.__class_name.ljust(20) + output += " [ DEBUG ] " + output += self.__class_name.ljust(24) output += ": " output += message print(output) @@ -188,23 +190,34 @@ def __init__(self): ) # Starts the input stream - def start(self): + def start(self) -> None: self.__stream.start_stream() # Stops the input stream - def stop(self): + def stop(self) -> None: self.__stream.stop_stream() - - # Listens to input stream and returns a list of frames - def listen(self) -> list[int]: - frames: bytes = self.__stream.read(2048) + + # Converts frames from bytes to a list of integers + def __convertFrames(frames: bytes) -> list[int]: res: list[int] = [] for i in range(0, len(frames) - 1, 2): res.append(int.from_bytes(frames[i:i+2], "little", signed=True)) return res - + + # Listens to input stream and returns a list of frames + def listen(self) -> list[int]: + return SoundInput.__convertFrames(self.__stream.read(2048)) + + # Loads a wav file and returns a list of frames + # Input file must be 48khz 16-bit mono + def loadFromFile(filename: str) -> list[int]: + with wave.open(filename, 'rb') as f: + return SoundInput.__convertFrames( + f.readframes(f.getnframes()) + ) + # Closes the input stream - def close(self): + def close(self) -> None: self.__stream.close() """ @@ -221,16 +234,34 @@ def __init__(self): output = True ) self.__stream.start_stream() - - # Writes frames to the output stream and blocks - def play(self, frames: list[int]): - out_frames: bytearray = [] + + # Converts frames from a list of integers to bytes + def __convertFrames(frames: list[int]) -> bytes: + res: bytearray = [] for i in range(0, len(frames) - 1, 2): frame = frames[i].to_bytes(2, 'little', signed=True) - out_frames.extend(frame * 2) - self.__stream.write(bytes(out_frames), len(frames), - exception_on_underflow=False) - + res.extend(frame * 2) + return bytes(res) + + # Writes frames to the output stream and blocks + def play(self, frames: list[int]) -> None: + self.__stream.write( + SoundOutput.__convertFrames(frames), + len(frames), + exception_on_underflow=False + ) + + # Writes frames to a wav file + # Output file will be 48khz 16-bit mono + def writeToFile(filename: str, frames: list[int]) -> None: + with wave.open(filename, 'wb') as f: + f.setnchannels(1) + f.setsampwidth(2) + f.setframerate(48000) + f.writeframes( + SoundOutput.__convertFrames(frames) + ) + # Closes the output stream def close(self): self.__stream.stop_stream() @@ -250,7 +281,7 @@ def __init__(self, baud_rate: int = 1200, amp_start_threshold: int = 18000, self.__mark_tone: list[int] = Waveforms.getMarkTone(baud_rate) self.__training_cycle: list[int] = Waveforms.getTrainingCycle(baud_rate) self.__sound_in: SoundInput = SoundInput() - self.__log = Log("Receiver") + self.__log = Log("afskmodem.Receiver") # Amplifies a received signal def __amplify(self, chunk: list[int]) -> list[int]: @@ -367,7 +398,7 @@ def __bitsToBytes(self, bits: str) -> bytes: i += 8 return bytes(res) - # Receives data and returns it (or fails) + # Receives signal, decodes it, then returns it (or fails) def receive(self, timeout: float) -> bytes: self.__log.info("Listening...") recv_audio = self.__listen(int(timeout * 48000)) @@ -382,6 +413,18 @@ def receive(self, timeout: float) -> bytes: dec_bytes = self.__bitsToBytes(dec_bits) self.__log.debug("Decoded " + str(len(dec_bytes)) + " bytes.") return dec_bytes + + # Reads signal from a file, decodes it, then returns it (or fails) + def read(self, filename: str) -> bytes: + recv_bits = self.__decodeBits(SoundInput.loadFromFile(filename)) + if(recv_bits == ""): + self.__log.warn("No data.") + return b"" + dec_bits = ECC.decode(recv_bits) + dec_bytes = self.__bitsToBytes(dec_bits) + self.__log.debug("Decoded " + str(len(dec_bytes)) + " bytes.") + return dec_bytes + """ Transmitter manages a line to the default audio output device @@ -394,7 +437,7 @@ def __init__(self, baud_rate: int = 1200, training_time: float = 0.5): self.__mark_tone = Waveforms.getMarkTone(baud_rate) self.__training_cycle = Waveforms.getTrainingCycle(baud_rate) self.__sound_out = SoundOutput() - self.__log = Log("Transmitter") + self.__log = Log("afskmodem.Transmitter") # Convert bytes to bits def __bytesToBits(self, b_in: bytes) -> str: @@ -402,10 +445,8 @@ def __bytesToBits(self, b_in: bytes) -> str: for i in range(len(b_in)): bits += '{0:08b}'.format(b_in[i]) return bits - - # Transmits the given data. - def transmit(self, data: bytes): - self.__log.info("Transmitting " + str(len(data)) + " bytes...") + + def __getFrames(self, data: bytes) -> bytes: frames: list[int] = [] message_bits = self.__bytesToBits(data) ecc_bits = ECC.encode(message_bits) @@ -422,5 +463,15 @@ def transmit(self, data: bytes): else: frames.extend(self.__mark_tone) frames.extend([0] * 4800) + return frames + + # Transmits the given data. + def transmit(self, data: bytes): + self.__log.info("Transmitting " + str(len(data)) + " bytes...") + frames: bytes = self.__getFrames(data) self.__log.info("Transmitting " + str(len(frames)) + " frames...") self.__sound_out.play(frames) + + # Transmits the given data, saving the resulting audio to a .wav file. + def write(self, data: bytes, filename: str): + SoundOutput.writeToFile(filename, self.__getFrames(data)) diff --git a/rx-demo-file.py b/rx-demo-file.py new file mode 100644 index 0000000..e61d777 --- /dev/null +++ b/rx-demo-file.py @@ -0,0 +1,24 @@ +from afskmodem import Receiver + +def main(): + receiver = Receiver(1200) + print("AFSKmodem File Read Demo") + while(True): + print("Enter path to file to read (ex. \"./myfile.wav\"):") + rxData = receiver.read(input()) + if(rxData == b""): + print("Could not decode.") + else: + print("Transmission decoded:") + print("") + print(rxData.decode("utf-8", "ignore")) + print("") + print("Done. (CTRL-C to exit)") + + +if(__name__ == "__main__"): + try: + main() + except(KeyboardInterrupt): + print("CTRL-C") + pass \ No newline at end of file diff --git a/rx-demo.py b/rx-demo.py index 7dcd685..7fe1e09 100644 --- a/rx-demo.py +++ b/rx-demo.py @@ -4,13 +4,15 @@ def main(): receiver = Receiver(1200) print("AFSKmodem RX Demo") while(True): - print("Waiting for message...\n") + print("Waiting for message...") while(True): rxData = receiver.receive(100) if(rxData != b""): - print("Transmission received.") + print("Transmission received:") + print("") print(rxData.decode("utf-8", "ignore")) - print("\nDone. (CTRL-C to exit)") + print("") + print("Done. (CTRL-C to exit)") break if(__name__ == "__main__"): diff --git a/tx-demo-file.py b/tx-demo-file.py new file mode 100644 index 0000000..1e76461 --- /dev/null +++ b/tx-demo-file.py @@ -0,0 +1,21 @@ +from afskmodem import Transmitter + +def main(): + transmitter = Transmitter(1200) + print("AFSKmodem File Write Demo") + while True: + print("Enter message string (ASCII):") + userMessage = input() + print("Enter path to save file at (ex. \"./myfile.wav\"):") + filename = input() + print("Saving to file...") + transmitter.write(userMessage.encode("ascii", "ignore"), filename) + print("Done. (CTRL-C to exit)") + print("") + +if(__name__ == "__main__"): + try: + main() + except(KeyboardInterrupt): + print("CTRL-C") + pass \ No newline at end of file diff --git a/tx-demo.py b/tx-demo.py index 9030fa4..967e16a 100644 --- a/tx-demo.py +++ b/tx-demo.py @@ -8,7 +8,8 @@ def main(): userMessage = input() print("Transmitting...") transmitter.transmit(userMessage.encode("ascii", "ignore")) - print("Done. (CTRL-C to exit)\n") + print("Done. (CTRL-C to exit)") + print("") if(__name__ == "__main__"): try: