הדגמה ליצירת קובץ wav (כמעט) מאפס

כאשר הייתי מתכנת צעיר, ראיתי קוד אשר לוקח struct (או משהו שמאגד שדות) שנשמר לקובץ, או נטען מקובץ בינארי.
לא הבנתי בדיוק איך זה עובד. כלומר הבנתי מה זה struct, הבנתי מה הוא מספק לי כמתכנת, אבל לא הבנתי איך זה עובד מול קובץ. כיום אני מבין את זה ואנסה להסביר כאן על מנת להסביר את הנושא של יצירת קובץ wav.

הסיבה שאני מתעקש להסביר איך struct עובד עבור קובץ בינארי, היא משום שזה הטריק חשוב מאוד שמקל עלינו על החיים, ואעשה זאת על ידי הסבר כיצד יוצרים קובץ wav בלי להשתמש בספרייה כלשהי.

אשתמש בשפת Go לשם כך, אבל במידה וזו אינה השפה שלכם, תוכלו לממש את זה בכל שפת תכנות שתרצו בפועל, כלומר אין מגבלה כאן, אך יכול להיות שהצורה לבנית מבנה הנתונים תהיה שונה בהתאם לצורה בה השפה עובדת.

מה הוא struct?

בצורה המופשטת שלו, struct זה אוסף של שדות המכילות מבנה נתונים מוגדר בעל גודל מוגדר לפי חישוב גודל לפי שדה וגודל כלל השדות מחליט על גודל הstruct.

בעברית אנחנו נקרא ל struct "רשומה". המונח כמעט זהה למבנה טבלאי במסד נתונים רלציוני (במקרה הזה) ברעיון שלו (אך המימוש שונה במסד נתונים).

למשל במידה ויהיה הקוד הבא:

type S struct {
  B1 byte
  B2 byte
}

יש מבנה נתונים של 2 בתים: B1 ו B2. כלומר יש לי רשומה של 2 בתים וזה גודל הרשומה.

כלומר פעולת sizeof (אופרטור/פונקציה – בהתאם לשפה המחזירה גודל בבתים של מבנה או טיפוס נתונים) יחזיר לנו "2" כלומר מבנה הנתונים מחזיק 2 בתים.

אם הייתי מוסיף גם שדה מסוג uint64 מבנה הנתונים היה עכשיו בגודל 16 בתים.

ברשומה שלמעלה לכל שדה יש טווח מספרים כאשר במקרה הזה בין 0 ל255, כלומר אני יכול לייצג 256 מספרים שהם חיוביים.

הייצוג של הרשומה למעלה זהה לדבר הבא:

var B1, B2 byte

אך רק כיצוג של 2 בתים. אך כאן אין רשומה, ואין שדות, אלא רק משתנים שביחד מספקים 2 בתים.

אז למה לעבוד עם רשומה?
ובכן יש מספר סיבות לכך והנה חלקם:

  • יצירת טיפוס נתונים שמחזיק כבר מבנה ידוע – חיסכון בקוד החוזר על עצמו.
  • סדר של מידע לפי צורך מסוים כאשר המיקום חשוב. כאשר המיקום אינו חשוב, לא "מרגישים" את הסדר.
  • יכולת לקרוא ולתחזק קוד בצורה פשוטה יותר מאשר משתנים אשר צריך לחפש אותם.
  • קל יותר לחשב גודל כולל של מידע שצריך אותו. או לפחות הגודל בתוך מבנה הנתונים עצמו (במקרה כאן של wav).
  • קל יותר להעביר מידע רב כמשתנה אחד – יש הרבה פעמים מגבלה של כמות ארגומנטים שניתן להעביר לפונקציה שמבוססת מערכת הפעלה, מעבד ו/או שפה (כאשר מדובר בשפה מפורשת).

קצת על קובץ Wav(e)

קובץ wav הוא סוג של מיכל (כלומר container) עבור מידע מסוג קול (אודיו) בלבד מבית מיקרוסופט.
הקובץ יודע להחזיק מידע raw כלומר מידע שלא עבר עיבוד (PCM ו ADPCM) וכן של codec. כאשר אנחנו מדברים על codec אנו מדברים על ייצוג של מידע בצורה מעובדת, כדוגמת קיבוץ מידע, סימון מידע כדוגמת מתי מתחיל ונגמר שקט או כל דבר אחר אשר מכיל את המידע הגולמי בצורה מעובדת שיכולה גם להיות קטנה יותר מהמקור או לציין בצורה דיגיטלית מידע שלא ידוע בצורה "טהורה" של קול או ווידאו.

בנוסף לבסיס, הפורמט של קובץ ה wav יודע לעבוד עם "חתיכות" מידע, לרוב באמצעות מבנה בשם RIFF.
פורמט קובץ הwav יכול להיות כחלק מcontainer אחר בשם AVI אשר גם הוא הגיע מבית מיקרוסופט, והפורמט יודע לשמור מידע של תמונה נעה (ווידאו) וכן אודיו לרוב בפורמט של WAV עם RIFF.
כאמור, קובץ הWAV נוצר על ידי חברת מיקרוסופט, והוא למעשה פורמט מאוד "בסיסי" (אך לא פשוט) במערכת ההפעלה של החברה – Windows.


מימוש "פשוט" של קובץ Wav

עכשיו שהצגתי בקצרה מה זה struct ופורמט ה wav או wave, אני רוצה להראות כמה קל (יחסית) ליצור מימוש פשוט שלו, המכיל "חתיכה" אחת בלבד, ועם מידע שהוא raw (ומשהו כבר יצר את המידע הזה שהוא מסוג PCM).

את הספסיפיקציה עליה אני התבססתי ניתן למצוא בקישור הבא.

אתחיל ביצירת מספר טיפוסי נתונים וקבועים עבור הפורמט הרצוי בשפת גו:

// WaveFormat holds information regarding the Codec/Format a wav chunk holds.
type WaveFormat uint16

// ChannelType holds the number of channels to use
type ChannelType uint16


// The following constants represents WAV codecs
// The constants does not represents all available Codecs
const (
	PCM        WaveFormat = 0x0001
	IEEEFloat  WaveFormat = 0x0003
	ALaw       WaveFormat = 0x0006
	MuLaw      WaveFormat = 0x0008
	Extensible WaveFormat = 0xFFFE
)

// Header constants
var (
	RIFF = [4]byte{'R', 'I', 'F', 'F'}
	WAVE = [4]byte{'W', 'A', 'V', 'E'}
	FMT  = [4]byte{'f', 'm', 't', ' '}
	DATA = [4]byte{'d', 'a', 't', 'a'}
)

// Type of channels
const (
	Mono   ChannelType = iota + 1 // single channel (mono)
	Stereo                        // dual channels (stereo)
)

המימוש למעלה יוצר טיפוסי נתונים עבור סוג התוכן שיש ל Wav וכן כמה ערוצי מידע (של שמע) יש לקובץ (מונו – ערוץ אחד, סטריאו – שני ערוצים).

הקבועים לוקחים את טיפוסי הנתונים ומאפשרים להשתמש בשם במקום בערך "קבוע" בתוך הקוד.

בנוסף הכנתי גם חלקי Header שונים שהם "קבועים" (אבל Go מכריחה אותי להשתמש בהם כמשתנים בשל מבנה הנתונים שלהם – מערך קבוע של בתים).

עכשיו לטיפוסי הנתונים אוסיף פונקציות עזר להציג אותן כמחרוזות:

func (wf WaveFormat) String() string {
	var format string
	switch wf {
	case PCM:
		format = "PCM "
	case IEEEFloat:
		format = "IEEE Float "
	case ALaw:
		format = "A-Law "
	case MuLaw:
		format = "µ-law "
	case Extensible:
		format = "Extension "
	}

	return fmt.Sprintf("%s0x%x", format, uint16(wf))
}

func (ct ChannelType) String() string {
	switch ct {
	case Mono:
		return "mono"
	case Stereo:
		return "stereo"
	default:
		return fmt.Sprintf("%d", ct)
	}
}


עכשיו נשאר ליצור את מבני הנתונים שאנו זקוקים להם בשביל לשמור PCM:

// RIFFChunk holds basic information about a given RIFF format.
type RIFFChunk struct {
	RIFFID     [4]byte // RIFF Header Magic header
	ChunkSize  uint32  // RIFF Chunk Size
	WaveHeader [4]byte // WAVE Header
}

// FormatChunk specifies the format of the data.
type FormatChunk struct {
	FormatID      [4]byte     // FMT header
	Size          uint32      // Size of the fmt chunk (16, 18 or 40)
	Format        WaveFormat  // Audio format
	ChannelsNum   ChannelType // Number of channels
	SamplesPerSec uint32      // Sampling Per seconds
	BytesPerSec   uint32      // average bytes per seconds
	BlockAlign    uint16      // Data block size
	BitsPerSample uint16      // Number of bits per sample
	DataID        [4]byte     // "data"  string
	DataSize      uint32      // Sampled data length
}

מבנה ה headers למעשה לקוחים מהספסיפיקציה, אבל אינם מכילים את כל המצבים, אלא רק עבור המצב הפשוט של PCM.

רשומת RIFFChunk מכילה מבנה נתונים של תת הפרוטוקול RIFF המייצג מידע על מידיה.

RIFFID הוא מזהה אשר לפי הפרוטוקול חייב להיות בעל 4 בתים ולהכיל את התווים שאומרים R I F F המייצגים את הפרוטוקול.

ChunkSize מכיל את גודל המידע (רשומה) של מה שאנחנו רוצים לקרוא (כלומר מבנה המידע, לא התוכן).

WaveHeader מיצג מה סוג ה Fomat שאנחנו משתמשים בו, והוא שהוא 4 בתים ובמקרה הזה צריך תמיד להיות התווים W A V E בשביל הצורך שלנו.

הרשומה FormatChunk מכילה תמיכה ב Chunk או "חתיכה" של מידע.
החלק הזה מכיל metadata שיאפשר לדעת מה מחזיק המידע בפועל בשביל שיהיה אפשר ולנסות לפרש אותו בצורה הזו.

אקפוץ רגע להגדרת השימוש:

s := uint32(unsafe.Sizeof(FormatChunk{}))
	l := uint32(len(data))

	riff := RIFFChunk{
		RIFFID:     RIFF,
		ChunkSize:  l + s - 8,
		WaveHeader: WAVE,
	}

	chunk := FormatChunk{
		FormatID:      FMT,
		Size:          16,
		Format:        PCM,
		ChannelsNum:   Mono,
		SamplesPerSec: 8000,
		BitsPerSample: 16,
		DataID:        DATA,
		DataSize:      l + s - 44,
	}
	chunk.BytesPerSec = chunk.SamplesPerSec * uint32(chunk.ChannelsNum) * uint32(chunk.BitsPerSample) / 8
	chunk.BlockAlign = uint16(chunk.ChannelsNum) * chunk.BitsPerSample / 8

אני מתחיל בלשמור למשתנה את גודל הרשומה של FormatChunk וכן את גודל הבתים של המידע בפועל אשר נשמר ל byte slice.

הגדרת הכרזות ובנוסף הגדרת גודל FormatChunk עבור RIFF שיגיע לגודל הנכון לפי הספסיפיקציה (צריך להפחית רק 8 בתים במקרה שלנו).

בנוסף יש הגדרות קצת לFormatChunk, אבל אסביר רק את החישובים.

בגדול מאוד אנחנו עובדים ב 8,000 הרצים או 8KHz וכל החישובים הבאים הם לפי זה.

DataSize הוא גודל המידע פחות ה offset שה Struct הספציפי הזה של FomatChunk יהיה בו (מבחינת גודל).

בגדול מאוד – BlockAlign הוא סה"כ גודל המידע לפי קצב הדגימה מבחינת מבנה הנתונים. ו BytesPerSec הוא קצב הדגימה בבתים מול מבנה המידע שלנו.

שמירת המידע נעשת בצורה הבאה:

// WriteWavFile Generate a new wav file based on data
func WriteWavFile(filename string, riff RIFFChunk, format FormatChunk, data []byte) error {
	f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY, 0664)
	if err != nil {
		return err
	}
	defer f.Close()

	binary.Write(f, binary.LittleEndian, riff)
	binary.Write(f, binary.LittleEndian, format)
	binary.Write(f, binary.LittleEndian, data)
	f.Sync()
	return nil
}

הרישום הראשון יהיה riff והשני יהיה של FormatChunk והשלישי יהיה המידע עצמו.

חשוב להדגיש כי במקרה הזה אינני בודק האם הכתיבה הצליחה או לא, ואם הקוד היה נועד לריצה במצב אמיתי (production), הייתי חייב לבדוק ולדעת אם נכשל או לא ולהגיב בהתאם.


במידה וכל מה שיצרנו עבד, נקבל את הפלט הבא מהפקודה file:

test.wav:  RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, mono 8000 Hz

וניגון בכל נגן אודיו התומך ב wav עם PCM ינגן זאת ללא בעיה.

במידה ויש בעיה במה שיצרנו, אחת הטעויות יכולה להגיד כי מדובר בתוכן raw שלא נתמך על ידי הנגנים (אבל ffmpeg כן יודע להתמודד איתו).

ניתן למצוא את כל הקוד שיצרתי כפרוייקט github.

להשאיר תגובה

הזינו את פרטיכם בטופס, או לחצו על אחד מהאייקונים כדי להשתמש בחשבון קיים:

הלוגו של WordPress.com

אתה מגיב באמצעות חשבון WordPress.com שלך. לצאת מהמערכת /  לשנות )

תמונת גוגל

אתה מגיב באמצעות חשבון Google שלך. לצאת מהמערכת /  לשנות )

תמונת Twitter

אתה מגיב באמצעות חשבון Twitter שלך. לצאת מהמערכת /  לשנות )

תמונת Facebook

אתה מגיב באמצעות חשבון Facebook שלך. לצאת מהמערכת /  לשנות )

מתחבר ל-%s

אתר זו עושה שימוש ב-Akismet כדי לסנן תגובות זבל. פרטים נוספים אודות איך המידע מהתגובה שלך יעובד.