gulp-maschine-id3
Version:
Gulp plugin for adding maschine-aware metadata chunk to WAVE file.
387 lines (362 loc) • 11.6 kB
text/coffeescript
###
Gulp plugin for adding maschine metadata to wav file
- API
- id3(data)
- data
object or function to provide data
- data.name String Optional default: source filename
- data.author String
- data.vendor String
- data.comment String
- data.deviceType String 'LOOP' or 'ONESHOT'
- data.bankchain Array of String
- data.types 2 dimensional Array of String
- data.modes Array of String
- data.syncFilename bool - use data.name as filenam. default: true
- data.removeUnnecessaryChunks bool - remove all chunks except 'fmt ' or 'data' chunk. default: true
- function(file, chunks[,callback])
function to provide data
- file instance of vinyl file
- chunks Array of object
RIFF chunks of source file
element properties
- id String chunk id
- data Buffer contents of chunk
- callback function(err, data)
callback function to support non-blocking call.
- Usage
id3 = require 'gulp-maschine-id3'
gulp.task 'hoge', ->
gulp.src ["*.wav"]
.pipe id3 (file, chunks) ->
name: "Hogehoge"
vendor: "Hahaha"
author: "Hehehe"
bankchain: ['Fugafuga', 'Fugafuga 1.1 Library']
comment: "uniuni"
deviceType: 'LOOP'
types: [
['Bass', 'Synth Bass']
]
modes: ['Additive', 'Analog']
.pipe gulp.dest "dist"
###
assert = require 'assert'
path = require 'path'
through = require 'through2'
gutil = require 'gulp-util'
_ = require 'underscore'
msgpack = require 'msgpack-lite'
riffReader = require 'riff-reader'
riffBuilder = require './riff-builder'
PLUGIN_NAME = 'maschine-id3'
DEFAULT_NKS =
__ni_internal:
source: 'other'
author: ''
bankchain: ''
comment: '',
deviceType: 'ONESHOT',
modes: []
name: ''
tempo: 0,
types: []
vendor: ''
module.exports = (data) ->
through.obj (file, enc, cb) ->
alreadyCalled = off
id3 = (err, data) =>
if alreadyCalled
@emit 'error', new gutil.PluginError PLUGIN_NAME, 'duplicate callback calls.'
return
alreadyCalled = on
if err
@emit 'error', new gutil.PluginError PLUGIN_NAME, err
return cb()
try
if data
_id3 file, data
@push file
catch error
@emit 'error', new gutil.PluginError PLUGIN_NAME, error
cb()
unless file
id3 'Files can not be empty'
return
if file.isStream()
id3 'Streaming not supported'
return
if _.isFunction data
try
chunks = _parseSourceWavChunks file
providedData = data.call @, file, chunks, id3
catch error
id3 error
if data.length <= 2
id3 undefined, providedData
else
try
_parseSourceWavChunks file
catch error
return error
id3 undefined, data
# replace or append ID3 chunk to file
#
# @data object - metadata
# @wreturn Array - chunks in source file
# ---------------------------------
_parseSourceWavChunks = (file) ->
chunks = []
src = if file.isBuffer() then file.contents else file.path
json = undefined
riffReader(src, 'WAVE').readSync (id, data) ->
chunks.push
id: id
data: data
ids = chunks.map (chunk) -> chunk.id
assert.ok ('fmt ' in ids), '[fmt ] chunk is not contained in file.'
assert.ok ('data' in ids), '[data] chunk is not contained in file.'
file.chunks = chunks
# replace or append ID3 chunk to file
#
# @data Object - metadata
# @wreturn Buffer - contents of ID3 chunk
# ---------------------------------
_id3 = (file, data) ->
extname = path.extname file.path
basename = path.basename file.path, extname
dirname = path.dirname file.path
# default options
data = _.defaults data,
name: basename
syncFilename: on
removeUnnecessaryChunks: on
# validate
_validate data
chunks = if data.removeUnnecessaryChunks
file.chunks.filter (c) -> c.id in ['fmt ', 'data']
else
# remove 'ID3 ' chunk if already exits.
file.chunks.filter (c) -> c.id isnt 'ID3 '
# rename
if data.syncFilename
file.path = path.join dirname, (data.name + extname)
# build wav file
wav = riffBuilder 'WAVE'
wav.pushChunk chunk.id, chunk.data for chunk in chunks
wav.pushChunk 'ID3 ', _build_id3_chunk data
file.contents = wav.buffer()
# build ID3 chunk contents
#
# @data Object - metadata
# @wreturn Buffer - contents of ID3 chunk
# ---------------------------------
_build_id3_chunk = (data) ->
nksFrame = _build_nks_frame data
nisoundFrame = _build_nisound_frame data
id3Header = new BufferBuilder()
.push 'ID3' # magic
.push [0x04, 0x00] # id3 version 4 -> 2.4.0
.push 0x00 # flags
# size id3header + nksFrame + nisoundFrame + padding 1024
.pushSyncsafeInt nksFrame.length + nisoundFrame.length + 1024
# return buffer
Buffer.concat [
id3Header.buf # ID3v2 header 10 byte
nisoundFrame # GEOB frame
nksFrame # GEOB frame
Buffer.alloc 1024, 0 # padding 1024byte
]
# build ID3 nisound GEOB frame
#
# @data Object - metadata
# @wreturn Buffer - contents of GEOB frame
# ---------------------------------
_build_nisound_frame = (data) ->
contents = new BufferBuilder()
# unknown, It seems all expansions sample are same.
.pushHex '000000'
.push 'com.native-instruments.nisound.soundinfo\u0000'
# unknown, It seems all expansions sample are same.
.pushHex '020000000100000000000000'
# sample name
.pushUcs2String data.name
# author name
.pushUcs2String data.author
# vendor name
.pushUcs2String data.vendor
# comment
.pushUcs2String data.comment
# unknown, It seems all expansions sample are same.
.pushHex '00000000ffffffffffffffff00000000000000000000000000000000'
# ??
.pushUInt32LE if data.deviceType is 'LOOP' then 4 else 3
# .pushUInt32LE 0
# unknown
.pushHex '01000000'
# bankchain
.pushUcs2StringArray data.bankchain
# types (category)
.pushUcs2StringArray _modesAndTypes data.modes, data.types
# maybe modes ?
# .pushUcs2StringArray data.modes
.pushHex '00000000'
# properties, It seems all expansions sample are same.
.pushKeyValuePairs [
['color', '0']
['devicetypeflags', if data.deviceType is 'LOOP' then '8' else '4']
['soundtype', '0']
['tempo', '0']
['verl', '1.7.14']
['verm', '1.7.14']
['visib', '0']
]
# header
frameHeader = new BufferBuilder()
.push 'GEOB' # frame Id
.pushSyncsafeInt contents.buf.length # data size
.push [0x00, 0x00] # flags
# return buffer
Buffer.concat [frameHeader.buf, contents.buf]
# build ID3 nisound GEOB frame
#
# @data Object - metadata
# @wreturn Buffer - contents of GEOB frame
# ---------------------------------
_build_nks_frame = (data) ->
nks = Object.assign {}, DEFAULT_NKS
dataKeys = Object.keys data
(Object.keys nks).forEach (key) ->
if dataKeys.includes key
nks[key] = data[key]
nksHeader = new BufferBuilder()
.pushHex '000000'
.push 'com.native-instruments.nks.soundinfo\u0000'
content = msgpack.encode nks
# header
frameHeader = new BufferBuilder()
.push 'GEOB'
# size of frame contents
.pushSyncsafeInt nksHeader.buf.length + content.length
.push [0x00, 0x00] # flags
# return buffer
Buffer.concat [frameHeader.buf, nksHeader.buf, content]
_modesAndTypes = (modes, types) ->
list = []
if modes
list.push "\\.#{mode}" for mode in modes
if types
for t in types
if t and t.length and t[0]
list.push "\\:#{t[0]}"
for t in types
if t and t.length > 1 and t[0] and t[1]
list.push "\\:#{t[0]}\\:#{t[1]}"
for t in types
if t and t.length > 2 and t[0] and t[1] and t[2]
list.push "\\:#{t[0]}\\:#{t[1]}\\:#{t[2]}"
_.uniq list
_validate = (data) ->
for key, value of data
throw new Error "Unknown data property: [#{key}]" unless key in [
'name'
'author'
'vendor'
'comment'
'deviceType'
'bankchain'
'tempo'
'types'
'modes'
'syncFilename'
'removeUnnecessaryChunks'
]
switch key
when 'name'
assert.ok _.isString value, "data.name should be String. #{value}"
when 'author'
assert.ok _.isString value, "data.author should be String. #{value}"
when 'vendor'
assert.ok _.isString value, "data.vendor should be String. #{value}"
when 'comment'
if value
assert.ok _.isString value, "data.vendor should be String. #{value}"
when 'bankchain'
if value
assert.ok _.isArray value, "data.bankchain should be Array of String. #{value}"
for v in value
assert.ok _.isString v, "data.bankchain should be Array of String. #{value}"
when 'deviceType'
if value
assert.ok _.isString value, "data.deviceType should be String. #{value}"
assert.ok value is 'LOOP' or value is 'ONESHOT', "data.deviceType should be 'LOOP' or 'ONESHOT'. #{value}"
when 'tempo'
if value
assert.ok _.isNumber value, "data.tempo should be number. #{value}"
when 'types'
if value
assert.ok _.isArray value, "data.types should be 2 dimensional Array of String. #{value}"
for v in value
assert.ok _.isArray v, "data.types should be Array of String. #{value}"
assert.ok v.length > 0 and v.length <= 3, "data.types lenth of inner array should be 1 - 3. #{value}"
for i in v
assert.ok _.isString i, "data.types should be 2 dimensional Array of String. #{value}"
when 'modes'
if value
assert.ok _.isArray value, "data.modes should be Array of String. #{value}"
for v in value
assert.ok _.isString v, "data.modes should be Array of String. #{value}"
# helper class for building buffer
# ---------------------------------
class BufferBuilder
constructor: ->
@buf = new Buffer 0
#
# @value byte or byte array or string
push: (value) ->
switch
when _.isNumber value
# byte
@buf = Buffer.concat [@buf, new Buffer [value]]
else
# string or byte array
@buf = Buffer.concat [@buf, new Buffer value]
@
pushUInt32LE: (value) ->
b = new Buffer 4
b.writeUInt32LE value
@buf = Buffer.concat [@buf, b]
@
# 7bit * 4 = 28 bit
pushSyncsafeInt: (size) ->
b = []
b.push ((size >> 21) & 0x0000007f)
b.push ((size >> 14) & 0x0000007f)
b.push ((size >> 7) & 0x0000007f)
b.push (size & 0x0000007f)
@push b
@
pushHex: (value) ->
@buf = Buffer.concat [@buf, (new Buffer value, 'hex') ]
@
pushUcs2String: (value) ->
l = if value and value.length then value.length else 0
@pushUInt32LE l
@buf = Buffer.concat [@buf, (new Buffer value, 'ucs2')] if l
@
pushUcs2StringArray: (value) ->
if (_.isArray value) and value.length
@pushUInt32LE value.length
@pushUcs2String v for v in value
else
@pushUInt32LE 0
@
pushKeyValuePairs: (value) ->
if (_.isArray value) and value.length
@pushUInt32LE value.length
for pair in value
@pushUcs2String "\\@#{pair[0]}"
@pushUcs2String pair[1]
else
@pushUInt32LE 0
@