UNPKG

gulp-bitwig-rewrite-meta

Version:
567 lines (516 loc) 16.6 kB
path = require 'path' through = require 'through2' _ = require 'underscore' PluginError = require 'plugin-error' PLUGIN_NAME = 'bitwig-rewrite-meta' # ucs2 encoding endian IS_UCS2_LE = (Buffer.from 'a', 'ucs2')[0] # bwpreset chunk1 # ---------------------------------------------- # Polysynth Acido.bwpreset (1.1 BETA 1) # # chunk identifier: 00000561 # --------------------------------- # item identifier: 00001423 (unknown) # value type: 05 (byte) # value: 00 # --------------------------------- # item identifier: 0000150a (unknown) # value type: 05 (byte) # value: 00 # --------------------------------- # item identifier: 00001421 (unknown) # value type: 09 (32bit) # value: 00000040 # --------------------------------- # item identifier: 000002b9 (unknown) # value type: 08 (string) # string size: 00000000 # --------------------------------- # item identifier 000012de (preset_name) # value type: 08 (string) # string size: 00000005 # value: 416369646f ("Acido") # --------------------------------- # item identifier: 0000 009a (device_name) # value type: 08 (string) # string size: 00000009 # value: 506f6c7973796e7468 ("Polysynth") # --------------------------------- # item identifer: 0000009b (device_creator) # value type: 08 (string) # string size: 00000006 # value: 426974776967 ("Bitwig") # --------------------------------- # item identifier: 0000009c (device_type) # value type: 08 (string) # string size: 0000000b # value: 496e737472756d656e7473 ("Instrument") # --------------------------------- # item identifier: 0000009d (unknown) # value type: 01 (byte) # value: 02 # --------------------------------- # item identifier: 0000009e (creator) # value type: 08 (string) # string size: 00000005 # value: 436c616573 ("Claes") # --------------------------------- # item identifier: 0000009f (comment) # value type: 08 (string) # string size: 00000000 # --------------------------------- # item identifier: 000000a1 (category) # value type: 08 (string) # string size: 00000004 # value: 42617373 ("Bass") # --------------------------------- # item identifier: 000000a2 (tags) # value type: 08 (string) # string size: 0000000d # value: 6861726d6f6e6963206d6f6e6f ("harmonic mon") # --------------------------------- # item identifier 000000a3 (unknown) end of meta # value type: 05 (byte) # value: 01 # --------------------------------- # item identifier: 0000137e (unknown) # value type: 05 (byte) # value: 01 # --------------------------------- # .... don't need any more # # ---------------------------------------------- # Spire BA Agress Dub 02.bwpreset (1.0.10) # # chunk identifier: 000001a5 # --------------------------------- # item identifier: 000002b9 (unknown) # value identifier: 08 (string) # string size: 00000000 # --------------------------------- # item identifier: 000012de (preset_name) # value type: 08 (string) # string size: 00000000 # --------------------------------- # item identifier: 0000009a (device_name) # value type: 08 (string) # string size: 00000005 # value: 5370697265 ("Spire") # --------------------------------- # item identifier: 0000009b (device_creator) # value type: 08 (string) # string size: 0000000c # value: 52657665616c20536f756e64 ("Reveal Sound") # --------------------------------- # item identifier: 0000009c (device_type) # value type: 08 (string) # string size: 00000005 # value: 53796e7468 ("Synth") # --------------------------------- # item identifier: 0000009d (unknown) # value type: 01 (byte) # value: 02 # --------------------------------- # item identifier: 0000009e (creator) # value type: 08 (string) # string size: 00000008 # value: 466163746f727931 ("Factory1") # --------------------------------- # item identifier: 0000009f (comment) # value type: 08 (string) # string size: 00000000 # --------------------------------- # item identifier: 000000a1 (category) # value type: 08 (string) # string size: 00000004 # value: 42617373 ("Bass") # --------------------------------- # item identifier: 000000a2 (tags) # value type: 08 (string) # string size: 00000000 # --------------------------------- # item identifier: 000000a3 (unknwon) end of meta # value type: 05 (byte) # value: 01 # --------------------------------- # .... don't need any more # constants $ = magic: 'BtWg' metaId: 'meta' presetType: type1: 0x000001a5 type2: 0x00000561 valueType: byte_1: 0x01 int16: 0x02 int32_1: 0x03 # since 1.2 some preset file's revision_no use 32bit int byte_2: 0x05 double: 0x07 string: 0x08 int32_2: 0x09 byte_array: 0x0d string_array: 0x19 protectedMetaItem: device_name: 0x009a device_creator: 0x009b device_category: 0x009c metaItem: name: 0x12de creator: 0x009e comment: 0x009f preset_category: 0x00a1 tags: 0x00a2 endOfMeta: 0x00a3 # supported header format headers: [ { regexp: /^BtWg[0-9a-f]{12}([0-9a-f]{8})0{8}([0-9a-f]{8})\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0004meta/ size: 52 contentAddress: 16 zipContentAddress: 32 } { regexp: /^BtWg[0-9a-f]{12}([0-9a-f]{8})0{8}([0-9a-f]{8})00\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0004meta/ size: 54 contentAddress: 16 zipContentAddress: 32 } { regexp: /^BtWg[0-9a-f]{12}([0-9a-f]{8})0{28}([0-9a-f]{8})\u0000\u0000\u0000\u0004\u0000\u0000\u0000\u0004meta/ size: 72 contentAddress: 16 zipContentAddress: 52 } ] module.exports = (data) -> through.obj (file, enc, cb) -> rewrited = off rewrite = (err, data) => if rewrited @emit 'error', new PluginError PLUGIN_NAME, 'duplicate callback' return rewrited = on if err @emit 'error', new PluginError PLUGIN_NAME, err return cb() try rewriteMeta file, data @push file catch err2 @emit 'error', new PluginError PLUGIN_NAME, err2 cb() unless file rewrite 'Files can not be empty' return if file.isNull() @push file return cb() if file.isStream() rewrite 'Streaming not supported' return if file.isBuffer() if _.isFunction data try obj = data file, (parseMetadata file), rewrite catch error rewrite error if data.length <= 2 rewrite undefined, obj else rewrite undefined, data # # rewrite metadata # ------------------------------------- # -file src file # -data function or object for rewrite rewriteMeta = (file, data) -> data = validateData data # analyze header headerStr = file.contents.toString 'ascii', 0, 80 headerData = undefined headerFormat = $.headers.find (fmt) -> headerData = headerStr.match fmt.regexp unless headerFormat throw new Error "Invalid file: unknown header format. file:#{file.path} header:#{file.contents.toString 'hex', 0, 80}" # content data offset content_offset = parseInt headerData[1], 16 # zip archive offset zip_content_offset = parseInt headerData[2], 16 reader = new BufferReader file.contents writer = new BufferWriter reader.position headerFormat.size new_metadata = replaceMetadata reader, writer, data # chunk1 reader.position content_offset writer.push reader.mark() # write new content offset address to header writer.writeHexInt writer.tell(), headerFormat.contentAddress if new_metadata.type is 'application/bitwig-preset' replacePresetChunk1 reader, writer, data # has zipped content if zip_content_offset reader.position zip_content_offset writer.push reader.mark() # write zipped content offset address to header writer.writeHexInt writer.tell(), headerFormat.zipContentAddress writer.push reader.end() # setup output file extname = path.extname file.path if data.name new_metadata.name = data.name dirname = path.dirname file.path file.path = path.join dirname, data.name + extname else new_metadata.name = path.basename file.path, extname file.contents = writer.buffer() file.data = new_metadata # parse metadata chunk # # return JSON object to explain metadata of original source file. parseMetadata = (file) -> # analyze header headerStr = file.contents.toString 'ascii', 0, 80 headerData = undefined headerFormat = $.headers.find (fmt) -> headerData = headerStr.match fmt.regexp unless headerFormat throw new Error "Invalid file: unknown header format. file:#{file.path} header:#{file.contents.toString 'hex', 0, 80}" reader = new BufferReader file.contents reader.position headerFormat.size extname = path.extname file.path ret = file: file.path name: path.basename file.path, extname # iterate metadata items while reader.readInt32() is 1 # read key kength key = reader.readString() unless key throw new Error "Invalid file: metadata item name can not be empty. file:#{file.path}" valueType = reader.readByte() value = undefined switch valueType when $.valueType.string value = reader.readString() if key is 'tags' value = if value then value.split ' ' else [] when $.valueType.int16 then value = reader.readInt16() when $.valueType.int32_1 then value = reader.readInt32() when $.valueType.double then value = reader.readDouble() when $.valueType.byte_array then value = reader.readBytes() when $.valueType.string_array size = reader.readInt32() value = for i in [0...size] reader.readString() else throw new Error "Unsupported file format: unknown value type. file:#{file.path} key:#{key} valueType:#{valueType}" ret[key] = value ret # reprace metadata chunk # # return JSON object to explain metadata replaceMetadata = (reader, writer, data) -> new_metadata = {} # iterate metadata items while reader.readInt32() is 1 # read key kength key = reader.readString() unless key throw new Error "Invalid file: metadata item name can not be empty." valueType = reader.readByte() value = undefined switch valueType when $.valueType.string if (key in _.keys $.metaItem) and (key in _.keys data) writer.push reader.mark() value = data[key] if key is 'tags' writer.pushString value.join ' ' else writer.pushString value reader.readString() reader.mark() else value = reader.readString() if key is 'tags' value = if value then value.split ' ' else [] when $.valueType.int16 then value = reader.readInt16() when $.valueType.int32_1 then value = reader.readInt32() when $.valueType.double then value = reader.readDouble() when $.valueType.byte_array then value = reader.readBytes() when $.valueType.string_array size = reader.readInt32() value = for i in [0...size] reader.readString() else throw new Error "Unsupported file format: unknown value type. key: #{key} valueType:#{valueType}" new_metadata[key] = value new_metadata # reprace chunk1 (.bwpreset only) replacePresetChunk1 = (reader, writer, data) -> chunkId = reader.readInt32() # iterate chunk1 items while (itemId = reader.readInt32()) isnt $.endOfMeta value = undefined valueType = reader.readByte() key = _.findKey $.metaItem, (v, k, o) -> v is itemId if key and key of data if valueType isnt $.valueType.string throw new Error "Unsupported file format: unknow value type. valueType:#{valueType}" writer.push reader.mark() oldValue = reader.readString() value = if key is 'tags' then data.tags.join ' ' else data[key] # old preset file dosen't have name value = '' if key is 'name' and oldValue is '' writer.pushString value reader.mark() else switch valueType when $.valueType.byte_1 then value = reader.readByte() when $.valueType.byte_2 then value = reader.readByte() when $.valueType.string then value = reader.readString() when $.valueType.int32_2 then value = reader.readInt32() else throw new Error "Unsupported File Format: unknown value type. itemId:#{itemId} valueType:#{valueType}" #---------------------------------------- # validate data object for rewrite #---------------------------------------- validateData = (data) -> data = data or {} keys = _.keys data if ('name' in keys) and not _.isString data.name throw new Error "option name must be string. name: #{data.name}" if ('creator' in keys) and not _.isString data.creator throw new Error "option creator must be string. creator: #{data.creator}" if ('comment' in keys) and not _.isString data.comment throw new Error "option comment must be string. comment: #{data.comment}" if ('preset_category' in keys) and not _.isString data.preset_category throw new Error "option preset_category must be string. preset_category: #{data.preset_category}" if 'tags' in keys unless _.isArray data.tags throw new Error "option tags must be array of strings. tags: #{data.tags}" for tag in data.tags unless _.isString tag throw new Error "option tags must be array of strings. tags: #{data.tags}" if (tag.indexOf ' ') >= 0 throw new Error "tag can't contain spaces. tags: #{tag}" data #---------------------------------------- # simple reader class #---------------------------------------- class BufferReader constructor: (buf) -> @buf = buf @marker = 0 @pos = 0 skip: (n) -> @pos += n @ position: (pos) -> @pos = pos @ tell: -> @pos readInt32: -> ret = @buf.readUInt32BE @pos @pos += 4 ret readInt16: -> ret = @buf.readUInt16BE @pos @pos += 2 ret readDouble: -> ret = @buf.readDoubleBE @pos @pos += 8 ret readByte: -> ret = @buf.readUInt8(@pos) @pos += 1 ret readHexInt: -> s = @buf.toString 'ascii',@pos, @pos + 8 @pos += 8 parseInt s, 16 readBytes: (len) -> unless len len = @readInt32() ret = '' if len ret = @buf.toString 'hex',@pos, @pos + len @pos += len ret readString: (len) -> enc = 'utf-8' unless len len = @readInt32() if len & 0x80000000 enc = 'ucs2' len = (len & 0x7ffffffff) << 1 ret = '' if len b = @buf.slice @pos, @pos + len if IS_UCS2_LE and enc is 'ucs2' b = swapBytes b ret = b.toString enc, 0, len @pos += len ret mark: -> ret = @buf.slice @marker, @pos @marker = @pos ret end: -> @buf.slice @marker #---------------------------------------- # simple writer class #---------------------------------------- class BufferWriter constructor: -> @buf = Buffer.alloc 0 buffer: -> @buf tell: -> @buf.length writeHexInt: (num, offset) -> s = "00000000#{num.toString 16}"[-8..] @buf.write s, offset, 8, 'ascii' push: (buf, start, end) -> b = buf if _.isNumber start if _.isNumber end b = buf.slice start, end else b = buf.slice start @buf = Buffer.concat [@buf, b] @ pushInt: (value) -> b = Buffer.alloc 4 b.writeUInt32BE value, 0 @push b @ pushString: (value) -> if value if /^[\u0000-\u007f]*$/.test value # ascii b = Buffer.from value, 'ascii' @pushInt b.length @push b if b.length else # value has non-ascii characters b = Buffer.from value, 'ucs2' if IS_UCS2_LE b = swapBytes b @pushInt 0x80000000 + (b.length >> 1) @push b if b.length else @pushInt 0 @ swapBytes = (b) -> l = b.length >> 1 for i in [0...l] p = i << 1 a = b[p] b[p] = b[p + 1] b[p + 1] = a b