I denna laboration ska ni definiera ett litet bibliotek för att generera musik som kan avlyssnas med vanliga musikprogram. Tiden medger inte något omfattande bibliotek, så den musik vi kan åstadkomma är begränsad. Syftet med denna laboration är att få övning i att använda en-dimensionella fält.
Lämna in samtliga källkodsfiler som en komprimerad zip-fil i Fire.
Ljud uppstår genom att en tryckvåg fortplantas genom luften; vi har alltså ett analogt fenomen. För behandling i datorer vill vi ha en digital representation av den analoga signalen. Vi betraktar följande figur:
Den grå analoga signalen har samplats (avlästs) vid diskreta tidpunkter (de vertikala streckade linjerna). Dessutom har signalvärdena (amplituderna) vid dessa tidpunkter avrundats till närmaste heltalsvärde, representerat av de horisontella streckade linjerna. Den samplade digitala signalen är alltså i detta fall [0, 4, 5, 4, 3, 4, 6, 7, 5, 3, 3, 4, 4, 3]
.
En vanlig standard för digital musik, som används för CD-skivor, har samplingsfrekvensen 44100 Hz (dvs antalet avläsningar av signalen per sekund). Noggrannheten i amplitud är typiskt 16 bitar, dvs \(2^{16} = 65536\) olika amplituder kan anges. I vårt bibliotek ska vi sampla signaler med denna frekvens, men representera amplituder med värden av typen double
, skalade så att amplituden alltid ligger mellan -1 och 1. Först i sista steget, i en klass som ni får given, gör vi om till 16 bitars värden för amplituden. En lämplig datatyp för musik är alltså ett fält av flyttal, dvs double[]
.
Vi kan göra två observationer:
Skapa en ny mapp för denna laboration och ladda ner zip-filen från kurshemsidan till denna mapp. Bekanta er sedan med det givna programmet.
Öppna filen Main.java
i IntelliJ. Bygga projektet och kör programmet (med hörlurar på!). Ni hör en ren sinussignal med frekvensen 440 Hz i två sekunder, följt av en lika lång signal en oktav högre.
Titta på filen Main.java
:
import java.io.File;
public class Main {
public static void main(String[] args) {
SoundDevice device = new SoundDevice();
Song song = new Song(5);
song.add(sine(440, 2));
song.add(sine(880, 2));
song.play(device);
song.save(device.getFormat(), new File("twotones.wav"));
}
public static double[] sine(double freq, double duration) {
int n = (int) (duration * SoundDevice.SAMPLING_RATE);
double[] a = new double[n];
double dx = 2 * Math.PI * freq / SoundDevice.SAMPLING_RATE;
for (int i = 0; i < n; i++) {
a[i] = Math.sin(i * dx);
}
return a;
}
}
Vi börjar med att titta på metoden main
. Först skapas två objekt, en SoundDevice
och en Song
:
SoundDevice
får ni given. Denna klass gör det möjligt att komma åt datorns ljudkort.Song
, som ni också får given, används för att avbilda en sång. Klassen innehåller metoden add
för att lägga till toner, metoden play
för att spela sången, samt metoden save
för att spara sången på en fil.Vi läser vidare i main
. Två gånger lägger vi till toner genom att anropa song.add
. Tonerna som läggs till är resultat av metoden sine
(som definieras efter main
). Metoden sine
producerar en sinussignal med given frekvens och längd. Därefter spelas sången (vilket ni hörde) genom att anropa song.play
. Med anropet song.save
sparas slutligen sången på en fil med namnet "twotones.wav"
. (Filen kan öppnas och spelas upp med datorns musik-spelare (om en sådan finns). Gör detta och kolla att ni höra de två tonerna.)
Vi är nu i en vanlig situation. Vi har fått ett program, provkört det, tittat på det och förstår en del av vad som pågår. Men en del är fortfarande konstigt. Vad betyder till exempel 5
i new Song(5)
?
I underkatalogen doc
finns dokumentation av de två klasserna Song
och SoundDevice
. Studera denna dokumentation och källkoden för de båda klasserna. Genom att läsa dokumentationen om Song
bör ni kunna förstå vad femman betyder.
För laborationen behöver vi inte förstå hur klassen SoundDevice
är uppbyggd. Det räcker att se att konstanten SAMPLING_RATE = 44100
definieras i denna klass.
Det återstår att förstå funktionen sine
som skapar och fyller ett fält med avläsningar av en ren sinusfunktion. Antalet värden i fältet är antalet avläsningar per sekund (SAMPLING_RATE
) gånger tonens längd i sekunder (duration).
Den funktion vi ska sampla är \(s(t) = \sin(2 \pi f t)\), där \(f\) är rekvensen (som heter freq
i metoden). Ska vi fylla fältet a
med SAMPLING_RATE
avläsningar av denna funktion så bör elementet med nummer i vara s(i / SAMPLING_RATE)
, eller sin(i * dx)
, där dx
definieras som i koden ovan.
Nu är det dags att skapa ett musikaliskt intressantare ljud än en ren sinuston. Er uppgift är att definiera en metod:
som genererar en ton med den givna frekvensen och varaktigheten och som liknar ljudet då man knäpper på en sträng på ett stränginstrument. För detta finns en berömd metod: Karplus-Strongs algoritm, uppfunnen för cirka 30 år sedan. Notera att metoden pluck
har samma parametrar som metoden sine
, dvs tonens frekvens och varaktighet. Trots det kommer den att låta annorlunda.
Börja med att skapa en ny klass MusicUtils
(i en ny fil MusicUtils.java
). Flytta dit sine
från Main
. När ni flyttat bort sine
från klassen Main
måste ni ändra användningen av den i metoden main
; ni måste nu skriva MusicUtils.sine
för att Java-kompilatorn ska hitta metoden. Gör det, kompilera om och kolla att ni fortfarande kan köra |Main| och höra de två sinustonerna.
Ni ska nu lägga till pluck
till klassen MusicUtils
. Karplus-Strongs algoritm fungerar på följande sätt:
sine
.p
vara antalet avläsningar per period, dvs samplingsfrekvensen 44100 dividerat med frekvensen freq
för den önskade tonen. Fyll de första p
elementen med slumptal i intervallet [-1.0, 1.0]
. För att få slumptal använder instansmetoden nextDouble()
i klassen Random
. Om ni tittar i API’n, ser ni att metoden nextDouble()
ger ett ett värde i intervallet [0.0, 1.0]
; hur gör man om det till ett tal mellan -1 och 1?Övriga element i fältet, elementen efter de p
första elementen, beräknas på följande sätt: elementet med index i
är summan av elementen med index
och Java i - (p - 1)
multiplicerad med en dämpkonstant K
. Ett lämpligt värde på K
är 0.498, men ni får gärna experimentera med olika värden (som ger olika ljud). Intressanta ljud fås bara för K
-värden litet mindre än 0.5.
Testa er metod genom att ändra i main
så att ni anropar pluck
i stället för sine
. Ändra också namnet på filen där ni sparar sången. Ni bör fortfarande höra två toner, men nu ska de låta som när man knäpper på en sträng.
Vi kan nu se att koden i sine
och pluck
har samma struktur: vi deklarerar och skapar ett fält, fyller det med innehåll och returnerar det. Innehållet blir olika i de två fallen och vi får därför olika ljud när vi spelar upp filen.
Hittills har vi angett toner genom att ge frekvens och varaktighet. Vi vill nu närma oss hur toner anges normalt i musik, dvs med noter i en skala. Vi kan inte gå igenom musikteori här, utan nöjer oss med att konstatera att på till exempel en pianoklaviatur finns tolv tangenter per oktav, sju vita och fem svarta:
Vi skall inte ange toner med de vanliga bokstäverna CDEFGAH, eftersom det kräver förtecken för de svarta tonerna och dessutom något sätt att ange oktav. Istället numrerar vi tonerna med heltal som på klaviaturen ovan, där ettstrukna A ges nummer 0 (vi väljer A som utgångspunkt, eftersom den, med frekvensen 440 Hz, oftast är referenspunkt för musikaliska skalor). Ettstrukna C är alltså -9, tvåstrukna C är 3, trestrukna C är 15, osv. Detta har också fördelen att vi lätt får en direkt formel för frekvensen för en given ton. Ton nummer k
har frekvensen:
\[ 440 \times 2^{\frac{k}{12}} Hz \]
Ettstrukna C har alltså frekvensen \(440 \times 2^{\frac{-9}{12}} = 261.63 Hz\).
Er uppgift är nu att utöka MusicUtils
med ytterligare en metod:
som genererar en ton med givet nummer (anges med parametern pitch
) och varaktighet (anges med parametern duration
). Observera: Det enda man behöver göra är att räkna ut frekvensen från tonens nummer, anropa pluck
för att få ett fält och returnera detta fält. I note
ska man varken skapa eller fylla fältet; det sköter pluck
om.
När ni gjort detta kan ni testa resultatet genom att ändra i main
så att programmet spelar till exempel början på Gubben Noak:
song.add(MusicUtils.note(-9, 0.4));
song.add(MusicUtils.note(-9, 0.4));
song.add(MusicUtils.note(-9, 0.4));
song.add(MusicUtils.note(-5, 0.4));
song.add(MusicUtils.note(-7, 0.4));
song.add(MusicUtils.note(-7, 0.4));
song.add(MusicUtils.note(-7, 0.4));
song.add(MusicUtils.note(-4, 0.4));
song.add(MusicUtils.note(-5, 0.4));
song.add(MusicUtils.note(-5, 0.4));
song.add(MusicUtils.note(-7, 0.4));
song.add(MusicUtils.note(-7, 0.4));
song.add(MusicUtils.note(-9, 1));
song.play(device);
Valet av 0.4 sekunder per ton här är en smaksak; ni kan välja annat tempo om ni så önskar. Vi ser också att det blir mycket omständligt att beskriva musik på detta sätt. Men innan vi ger oss på att åtgärda detta ska vi förbättra ljudet ytterligare.
Ett betydligt intressantare ljud fås om vi spelar flera toner samtidigt. Som ett första steg ska ni skriva en metod:
som genererar ett medelvärde av tonerna t1
och t2
. Vi förutsätter också att de två tonerna har samma varaktighet så att fälten är lika långa. Metoden average
ska helt enkelt skapa ett nytt fält och för varje index ta medelvärdet av motsvarande värden i t1
och t2
.
Nästa steg är att ni skall skriva en metod:
som skapar en ton med givet nummer och varaktighet genom att blanda tre toner som skapats med note
, nämligen tonerna med nummer pitch
, pitch-12
och pitch+12
(dvs också de två tonerna en oktav under och över den avsedda). Förslagsvis blandas först de två sistnämnda tonerna med metoden average
och sedan blandas resultatet med den första tonen, återigen med metoden average
.
Slutligen kan ni testa resultatet genom att i main
ersätta alla note
med harmonic
.
När ni kommit så här långt har ni definierat en biblioteksklass MusicUtils
som innehåller fem funktioner: sine
som ni fick given samt pluck
, note
, average
och harmonic
som ni definierat själva.
I main
ovan var beskrivningen av Gubben Noak en del av programmet. Mycket bättre är att spara beskrivningen av sången i en fil noak.txt
som innehåller:
-9 0.4
-9 0.4
-9 0.4
-5 0.4
-7 0.4
-7 0.4
-7 0.4
-4 0.4
-5 0.4
-5 0.4
-7 0.4
-7 0.4
-9 0.4
och sedan låta programmet läsa denna fil och med hjälp av informationen på varje rad skapa en ton som läggs till sången. Ännu bättre är kanske att ändra varaktigheten för tonerna i filen till 0.25, svarande mot en kvartston, och sedan separat ange tempot.
Ändra nu main
så att en sångbeskrivning läses från en fil genom att ge filnamnet som argument till main
-metoden. Koppla sedan filen till ett Scanner
-objekt. Detta görs på följande vis:
public static void main(String[] args) throws FileNotFoundException {
Scanner sc = new Scanner(new File(args[0]));
...
}
Klassen Scanner
har en konstruktor som göra att Scanner
-objektet läser data från en fil. En fil handhas som ett objekt av klassen File
och klassen File
har en konstruktor vars parameter är namnet på den fysiska filen.
Vid anropet av new File(arg[0])
inträffar en exception av typen FileNotFoundException
om den angivna fysiska filen inte finns. FileNotFoundException
tillhör en typ av exceptions som måste tas hand om eller kastas vidare. Här kastar vi FileNotFoundException
vidare, därav står det
i metodhuvudet till main
-metoden.
I er katalog finns också en fil elise.txt
som ni kan använda för testning, om ni inte vill skriva en egen fil för någon musik ni föredrar. Denna fil utnyttjar ovan antydda förbättring; varaktigheten 0.125 betyder en åttondelston. Om man låter detta vara synonymt med varaktigheten hos tonen i sekunder så blir det alldeles för högt tempo. Om en åttondel varar i 0.125 sekunder så hinner man 240 fjärdedelsnoter på en minut; 148 fjärdedelar per minut är mer lagom. Ändra programmet så att tempot är ett argument till main
-metoden.
Förhoppningsvis har ni nu lärt er en del programmering med fält och en del om digital audio. Avslutningsvis skall påpekas att vi i ett avseende gjort det enkelt för oss: vi har slösat med minne. Vi skapar många och långa fält också för korta ljudsekvenser. Det går att göra det vi gjort, och mer, med mycket mindre minneskonsumtion. För ett allvarligt menat musikprogram måste man vara mer ekonomisk, men för vårt syfte här lämpar sig en mer frikostig attityd till minnesförbrukning bättre.