File I/O - Programming for Musicians and Digital Artists: Creating music with ChucK (2015)

Programming for Musicians and Digital Artists: Creating music with ChucK (2015)

Appendix E. File I/O

Like all software, ChucK programs often need a way to save data for when they stop running and to reload that data when they resume running. They also need a way to read data generated by other programs or written manually by a human and to output data that can be used by another program or examined by a human. If you think this sounds like a good use for files, then you’re correct. Files are the backbone of any complex software ecosystem, and naturally ChucK provides several easy-to-use mechanisms to work with them.

E.1. File basics

The FileIO class is ChucK’s primary mechanism for interacting with files, providing a number of standard mechanisms for file input and output. Each instance of FileIO corresponds to a single file on disk that you can read and/or write to. Again, the venerable ChucK operator is overloaded to provide the main functionality for reading and writing to files, though we also need to introduce a new variant, the backchuck operator, which looks like this: <=. In the next sections, we’ll cover opening and writing files and then opening and reading files. We’ll then cover options for opening files (data types, restrictions, and so on), and then we’ll investigate nonsequential (jumping around) file access.

E.1.1 Opening and writing files

To read from or write to a file, you must first open it. Start by creating a FileIO object and then calling the .open() function on it, supplying a file path and some options:

FileIO file;

file.open("thefile.txt", FileIO.WRITE);

The first argument to the .open() function is the path of the file you want to open. This can be simply a single filename or a file path encompassing one or more directories, for example, path/to/directory/file.txt. In the miniAudicle, the default path can be found or changed under Preferences > Miscellaneous > Current Directory. In command-line ChucK, the current path is the location where ChucK is run. You can also create file paths relative to your ChucK script by using me.dir, as in me.dir() + "thefile.txt" (see chapter 4 and appendix B for more on me.dir).

The second argument provides options for opening the file, such as whether you want to read from or write to the file and whether to treat the file as ASCII text or binary data (more on that later). For now you only need to worry about FileIO.WRITE, which will open an ASCII-text file for writing, creating the file if necessary.

Opening a file with FileIO.WRITE will create the file if it doesn’t already exist and will also truncate the file, which means if the file already exists, any data in it will be erased. If any errors occur in opening the file, .open() will return false; otherwise it will return true. It’s usually a good idea to check the return value of .open(), printing an error message if the operation fails.

You can write data to the file using the backchuck operator, <=. A value or variable of any basic type can be backchucked to an open file, and it will be written to that file.

file <= 60; // write number '60' as ASCII characters to file

file <= " is a number"; // write string

file <= IO.newline(); // write newline

file <= "so is " <= 33.47 <= IO.nl(); // backchuck chain

The resulting text file will have the following contents:

60 is a number

so is 261.63

As shown here, backchuck operations can be chained in sequence to write multiple items in the same line; this is functionally the same as backchucking each item individually but can provide logical and aesthetic structure to your code. The previous code also backchucks IO.newline() to the file, which adds the character or character sequence representing a new line, typically \n on Mac OS X and Linux and \r\n on Windows. ChucK programs theoretically may be run on any of these OSes, so using IO.newline() or its abbreviated variant, IO.nl(), saves the programmer from having to be concerned as to which newline convention the underlying system actually uses.

Once you’ve finished writing to the file, you need to close it. This is achieved by the aptly named .close() function. Attempts to write to the file after calling .close() will fail:

file.close();

E.1.2 Reading files

Reading from a file is similarly straightforward. First, you open the file, indicating that you’d like to read it:

file.open("readfile.txt", FileIO.READ);

This function will fail (and return false) if the specified file doesn’t exist. Once the file is open, reading can proceed via the standard ChucK operator.

file => int i; // read an int

file => string s; // read a string

file => float f; // read a float

ChucKing the file object to a variable reads a value of that type from the file and stores the value in the variable. Values are read in linear order from the file, from first to last, until the end of the file is reached. Each read operation takes the next value, keeping track of which values have already been read. Within the file itself, values must be separated from each other, or delimited, by one or more whitespace characters: a single space, a new line, or a tab.

For example, consider the following file, fib.txt:

0 1 1 2

3 5 8 13

And consider the following program, reading this file:

FileIO file;

file.open("fib.txt", FileIO.READ);

while(true)

{

file => int val;

if(file.eof()) break;

<<< "next val:", val >>>;

}

This program will produce the following output:

next val: 0

next val: 1

next val: 1

next val: 2

next val: 3

next val: 5

next val: 8

next val: 13

This program also introduces another file function, .eof(). This function, whose name is an abbreviation of end of file, returns true if you’ve reached the end of the file and there’s no more data to read and returns false if more data is available. There is one catch: .eof() only returns true afteryou try to read from the file and there’s no more data left. That’s why the previous program calls .eof() after each read from file; the last read operation before the eof condition returns an invalid value, having reached the end of the file.

A companion function to .eof() is .more(), which returns the answer to the question, is there more data left? .more() always returns the negation of .eof(), so you can use either in your programs. Here you assume a successfully opened file of integers:

while(!file.eof()) file => int val; // read file until end

while(file.more()) file => int val; // same thing

And this structure works as well:

while(file => int val)

{

<<< "next val:", val >>>;

}

Often you’d like to read files one line at a time, because many file formats use new-lines as a delimiter for individual data items. You can’t use the ChucK operator to do this, but there is a function to help you out, .readLine(). This program prints out each individual line from a file; if you wanted to, you could do further processing on each line:

FileIO

file; file.open("lines.txt", FileIO.READ);

while(true)

{

file.readLine() => string line;

if(file.eof()) break;

<<< "next line:", line >>>;

}

E.1.3 Options for opening files

A few additional functions round out ChucK’s file I/O system. First, let’s take a closer look at the .open() function. As mentioned previously, this function takes two arguments: a file path and a set of options for opening the file. Besides FileIO.READ and FileIO.WRITE, there are a few additional options you can supply here. You can open a file for both reading and writing by combining these two options:

file.open("rwFile.txt", FileIO.READ | FileIO.WRITE); // read + write

The binary OR operator, |, can be used to combine different options, though not every combination of options will be valid (you’ll see which ones are invalid shortly). There’s an option, FileIO.READ_WRITE, that achieves the same thing as the previous code; using either a combination ofFileIO.READ | FileIO.WRITE or FileIO.READ_WRITE will produce the same results:

file.open("rwFile.txt", FileIO.READ | FileIO.WRITE); // read + write

file.open("rwFile.txt", FileIO.READ_WRITE); // same thing

Unlike FileIO.WRITE, opening a file for both reading and writing will not truncate the file, so you can safely read existing data while writing new data. Writing to that opened file will overwrite data already in that file, however, starting at the beginning (or other location, if .seek is used; seesection E.1.4).

With FileIO.APPEND you can open a file for appending, which means write operations will be added at the end of the file. This can be used, for example, to log data to a single file over several runs of a program, while preserving the history of data over these runs:

file.open("log.txt", FileIO.APPEND); // open for appending

file <= "appending:" <= Math.randomf() <= IO.nl();

file.close();

FileIO.APPEND can’t be combined with FileIO.READ, FileIO.WRITE, or FileIO.READ_WRITE; attempting to do so will cause .open() to return an error. As with FileIO.WRITE, if the file doesn’t already exist, it will be created.

Two more options for opening a file are FileIO.ASCII and FileIO.BINARY, corresponding to ASCII-mode files and binary-mode files, respectively. The default is ASCII, which basically means you’re reading or writing plain text to a file; all of the I/O we’ve examined so far in this appendix has been ASCII mode. In binary mode, values are written exactly as they’re encoded in memory, which uses your hard disk more efficiently but is usually harder to work with. It’s often better to use text files for the relatively small quantities of data dealt with in ChucK, and for this reason we won’t spend much time looking at binary files. Suffice it to say that you can combine FileIO.ASCII and FileIO.BINARY with any of the previous options, but they can’t be combined with each other, being mutually exclusive:

file.open("ascii.txt", FileIO.WRITE); // default (ASCII-mode)

file.open("ascii2.txt", FileIO.WRITE | FileIO.ASCII); // ASCII-mode

file.open("binary.txt", FileIO.WRITE | FileIO.BINARY); // binary-mode

file.open("binasc.txt", FileIO.WRITE | FileIO.ASCII | FileIO.BINARY); // error!

E.1.4. Non-sequential file access

So far we’ve looked at reading to and writing from files sequentially. You can reposition where you read/write in a file using .seek(). You can also determine the current position in the file with .tell(). Finally, you can determine the total size of the file with .size(). Each of these functions deals with byte position in the file, so .seek(4) moves the read/write pointer to just after the fourth byte in the file, and .size() returns the size in bytes:

file.seek(12); // read/write after 12th byte of file

file.seek(0); // go to beginning of file

file <= Math.randomf(); // write to file

<<< file.tell()>>>; // print current file position

file.seek(file.size()); // move to end of file

One last function that comes in handy when working with files is .good(). This function tells you whether the file is ready for the operation specified when you opened it (reading, writing, and the like). If it returns true, the file is ready; if false, the open operation failed, and attempting to read or write it will also fail:

file.open("aFile.txt", FileIO.WRITE);

if(!file.good()) { <<< "opening file failed" >>>; me.exit(); }

// otherwise, continue using file

E.2. Standard output and error

ChucK also has two built-in objects that represent special-purpose files. chout provides a way to write to the ChucK standard output file, and cherr similarly provides access to the ChucK standard error file. Usually, writing to either of these files simply causes the written data to appear on the Console Monitor, though advanced users may be interested to know that chout corresponds to console stdout and cherr corresponds to console stderr.

You can write to these the same way as with any file open for writing:

60 => int note;

chout <= "the note is " <= note <= " = " <= Std.mtof(note) <= " Hz" <= IO.nl();

cherr <= "there was no error!" <= IO.nl();

This prints

the note is 60 = 261.626 Hz

there was no error!

You can attempt to read from chout or cherr, but your program will just wait forever, because these files never produce any input. You also can’t close or reopen chout or cherr, and seeking doesn’t work either, because unlike standard FileIO objects, these files don’t normally correspond to actual files on disk. Instead they’re like ephemeral, endless streams of data; once you write to chout or cherr, you can’t unwrite or change it.

You might ask why we bother with chout and cherr when we already have the <<< >>> operator for printing to the console. chout and cherr behave a lot like regular FileIO objects, so any code that already uses FileIO can easily switch to chout and cherr. Additionally, chout / cherr provide more control over formatting and spacing of console output, such as when to jump to a new line or where spaces are placed. Command line users will appreciate that chout, by virtue of mapping to stdout, can be piped to other command line programs and utilities (see appendix G for more information about using ChucK on the command line).